diff --git a/jest.config.packages.cjs b/jest.config.packages.cjs index b65ce04b056..52ec2079f59 100644 --- a/jest.config.packages.cjs +++ b/jest.config.packages.cjs @@ -80,6 +80,12 @@ module.exports = { // Here we ensure that Jest resolves `@metamask/*` imports to the uncompiled source code for packages that live in this repo. // NOTE: This must be synchronized with the `paths` option in `tsconfig.base.json`. moduleNameMapper: { + '^@metamask/perps-controller/constants/(.+)$': [ + '/../perps-controller/src/constants/$1', + ], + '^@metamask/perps-controller/utils/(.+)$': [ + '/../perps-controller/src/utils/$1', + ], // Strip .js extensions from relative imports so Jest resolves them to // the TypeScript source files. '^(\\.{1,2}/.+)\\.js$': '$1', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 626b1252624..8219823fcbf 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4668,9 +4668,6 @@ } }, "packages/perps-controller/src/providers/LighterProvider.ts": { - "no-useless-return": { - "count": 5 - }, "typescript/prefer-nullish-coalescing": { "count": 1 } @@ -4765,21 +4762,6 @@ } }, "packages/perps-controller/tests/src/providers/LighterProvider.test.ts": { - "typescript/no-unsafe-argument": { - "count": 6 - }, - "typescript/no-unsafe-assignment": { - "count": 8 - }, - "typescript/no-unsafe-call": { - "count": 3 - }, - "typescript/no-unsafe-member-access": { - "count": 21 - }, - "typescript/no-unsafe-return": { - "count": 3 - }, "typescript/unbound-method": { "count": 5 } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1355449cdd6..e6e0a8ef338 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,16 +9,118 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `reconcileChaseOrderCancellation` to the controller, messenger and optional provider contract. Lighter reconciles an already attempted cancellation under its exact original owner, handle, child and transaction identity with local settlement writes but no new financial dispatch, even if acknowledgement becomes failure while the child remains open. Missing, malformed or stale original ownership rejects; unknown outcomes remain unresolved. Aggregated mode routes only the explicit provider and unsupported providers never fall back to ordinary cancellation ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Export `LIGHTER_SCALE_SETTLEMENT_WINDOW_MS`, `LIGHTER_SCALE_SETTLEMENT_POLL_MS`, `LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS` and `LIGHTER_CHASE_CANCEL_SETTLEMENT_POLL_MS` through the `constants` and `constants/lighterConfig` package entrypoints for shared settlement policy ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Support explicit independent Lighter partial TP/SL pairs, including captured full-position siblings, through cancellation-first durable per-leg dispatch. Expose supported pair linkages and role-bound protection receipts without changing native OCO defaults ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Support explicit Lighter `marginMode` selections with required leverage, authoritative position/order locks and exact mode transaction execution before dependent exposure. Ready active perpetual capabilities report both native modes ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add `getChaseOrderOwnership({handle, providerId, owner?})` to the controller, messenger and optional provider contract. Lighter returns validated durable local history for every exact child under its original wallet/network/account/key owner, including superseded and unresolved attempts, without signing, transport, automatic continuation or durable writes. Missing handles and owner mismatches return unavailable; corrupt storage rejects. Aggregated mode routes one explicit provider; providers without durable history return unsupported. Existing Chase and disabled TWAP behavior remain unchanged ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Add the default-off testnet-only `chaseTestnetProbe` constructor option to `LighterProvider` for bounded, durable native Chase cancel/replace. Production strategy capabilities remain unavailable ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Implement Lighter `getChaseOrders`, `suspendChaseOrders` and `cancelOrder` with `orderType: 'chase'`. Unbound or recordless absent accounts return empty management state; absent venue discovery retains remembered wallet/network cleanup obligations without signing authority. Confirmed cancel-on-stop reports `canceled`; native records retain the first `stopReason` and uncertain cleanup remains `termination_pending` ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Expose `LIGHTER_MAX_CLIENT_ORDER_INDEX`, `LIGHTER_NATIVE_PROBE_MAX_NOTIONAL` and `LIGHTER_CHASE_*` bounds/defaults through `constants/lighterConfig` for shared native identity and Chase policy validation ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Expose `utils/lighterChase.readLighterChaseQuote`, `reconcileLighterChaseChild` and `identifyLighterChaseChild` for native quotes, exact fill accounting and immutable child validation ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Reconcile failed or expired never-landed Chase transactions without releasing uncertain ownership. Keep accepted orders active through indexer lag, use absolute duration deadlines, and interrupt replacements synchronously when cancellation is requested. Retain TWAP exclusion when expired-placement order evidence is incomplete. Fence starts waiting on preparation, signer readiness or the account mutex when suspension occurs; preserve duration cleanup after a wrong-symbol cancellation. Report confirmed explicit cleanup independently of an earlier failed stop cause, and keep basis-point conversion independent of policy limits ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Support native Lighter same-order price and size edits for verified unfilled ordinary perpetual limits. Add `OrderResult.orderEdit` and `OrderEditObservation` for durable pending, settled, failed and terminal outcomes; acceptance alone is pending. Export the seven-argument `LighterSignModifyOrderWireParams` bridge contract. Trigger, strategy and protection children remain unsupported ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Preserve attached exact-failure settlement across review and nonce-ledger retries. Explicit attached review can settle fresh complete exact-leg acceptance into an acknowledgeable recovered outcome when the transaction hash is absent and its nonce advanced; saved IDs alone remain insufficient. Bound history by preparation time and page/row budgets, reporting optional `AttachedOrderGroup.historyStatus` for incomplete reviews ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Expose `OrderResult.attachedOrderGroup`, `AttachedOrderGroup`, `ReadyPerpsOrderCapabilities.attachedTpsl`, optional provider `getAttachedOrderGroups`/`reviewAttachedOrderGroups`, and controller messenger actions `PerpsControllerGetAttachedOrderGroupsAction`/`PerpsControllerReviewAttachedOrderGroupsAction`. The group submission union includes `completed` for exactly correlated terminal legs ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Export `LIGHTER_MAX_WIRE_PRICE`, `LIGHTER_GROUPING_ONE_TRIGGERS_THE_OTHER` and `LIGHTER_GROUPING_ONE_TRIGGERS_OCO` through `constants` and `constants/lighterConfig`. The public `utils/lighterAttachedOrders` subpath exposes `LighterAttachedGroup`, `parseLighterAttachedGroups`, `correlateLighterAttachedOrders`, `toAttachedOrderGroup`, `LIGHTER_ATTACHED_MAX_GROUPS` and `LIGHTER_ATTACHED_HANDLE_PREFIX` ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add optional `OrderParams.expectedScaleLadder` and exported `ExpectedScaleLadder` to bind Lighter Scale placement to approved prices, quantities, totals, minimums and size precision. Refuse stale or malformed constraints with `ORDER_SCALE_PREVIEW_STALE` before writes. Snapshot caller intent before readiness and fee waits, and recheck the complete normalized ladder and venue precision/minimums before signing and final dispatch, preserving accepted child receipts when later metadata changes. Omission preserves unbound placement-time normalization; other providers refuse the constraint ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Add optional `GetScalePriceLadderParams.sizing` for exact base size or bounded quote budget and optional skew. Lighter returns venue-valid `PerpsScalePriceLadder.sizingPreview` quantities, totals and maker minimums without account or signer setup; unsupported providers leave the preview absent ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add durable Lighter Scale ladders with exact-grid preview, aggregate exposure checks, uncertain-child retention and exact group cancellation ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add optional `getScaleOrderGroups` and `reviewScaleOrderGroups` provider methods and controller actions, with the exported `ScaleOrderGroup` inventory type ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Attribute real Lighter Scale order rows through `strategyGroupId`; keep rows that contradict durably rejected children unattributed and retain ordinary order reads when optional attribution is unavailable ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Reconcile Scale dispatch proof across review, expiry and nonce recovery, and poll bounded venue visibility after placement and cancellation ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add default-off bounded Lighter Chase probe coordination with durable child ownership, exact fill-aware cancel/replace, public management views and explicit termination; TWAP and production Chase capabilities remain unavailable. Expose `utils/lighterChase.readLighterChaseQuote`, `reconcileLighterChaseChild` and `identifyLighterChaseChild` for native quote, fill and immutable child validation ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Support ordinary Lighter post-only limit placement with native TIF 2, fresh crossing checks and durable dispatch recovery; TWAP and production Chase capabilities remain unavailable ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Add the default-off `nativeTwapTestnetProbe` constructor option for bounded Lighter native TWAP validation with durable account/network/slot ownership. Unresolved TWAP and Chase probes exclude further probe placement on the same account ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add provider-only `getNativeTwapObservations()` for exact parent/client-ID reads and complete child-trade reconciliation. Cancellation acknowledgment retains ownership pending independently verified terminal mapping ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Expose `utils/lighterTwapReconciliation.reconcileLighterTwapObservation` and `identifyLighterTwapParent` for exact native reconciliation and immutable cancellation ownership. Exact cancellation remains available when mutable fill history is unavailable ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Release provably unsent native probe transactions and expired, never-landed TWAP transactions while retaining attempted and uncertain cleanup obligations. Validate optional native trade string IDs against numeric counterparts ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Add `utils/lighterTwap.prepareLighterTwapOrder` for exact native TWAP wire preparation only. Keep Lighter TWAP unavailable pending authoritative parent-fill and terminal-cancellation semantics. Reject unsupported management reads and TWAP cancellation before signer setup rather than reporting an empty inventory or treating generic cancel acknowledgment as schedule termination ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Support fixed partial Lighter position protection with single triggers or equal-quantity OCO pairs, exact downward quantity normalization and cancellation-first replacement. Expose optional `positionTpsl.defaultCoverage`, `positionTpsl.partialCoverage` and `PerpsPendingManualRecovery.partialIntent`. Lighter reports snapshot defaults, equal-quantity OCO and explicit current-position recovery; `HYPERLIQUID_ORDER_CAPABILITIES` reports dynamic defaults, independent partial pairs and provider-default recovery. Release proven-unsent partial attempts without removing prior protection or unresolved dispatched obligations ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Expose `LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT` through `constants` and `constants/lighterConfig` to bound the durable wallet/network recovery account index. Record verified account identity before venue mutation so pending dispatches remain discoverable after account absence ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add native Lighter parent orders with attached TP/SL through OTO and OTOCO groups, durable parent/child identities, local `getAttachedOrderGroups`, explicit read-only `reviewAttachedOrderGroups`, and exact group cancellation. Preserve local groups for confirmed absent and known Premium accounts. Explicit partial child sizes and position linkage remain unsupported; activation, partial-fill coverage and automatic cancellation guarantees remain unverified. Persist exact terminal non-acceptance before nonce evidence retirement. Dispatched intent requires fresh absent transaction/order evidence after expiry, or exact transaction failure, before abandonment. Review traverses bounded history, batches reads per market, skips unchanged writes and reclaims only proven terminal groups ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add strict provider-scoped `reviewRecoveryVenue` and explicit `resolveRecoveryProtection` controller/provider capabilities, including opaque manual-obligation identities, original-slot reconciliation and durable source-to-successor protection settlement. Review uses matching registered read authority without registration or financial writes; unsupported providers report a distinct capability result. Manual and dispatch rows expose optional provider, wallet and network metadata; aggregate rows carry their owning provider. Ordinary protection changes refuse pending selected transfers across trading slots. Pending or unknown transaction statuses remain unresolved even when order books already match; executed (2), pending-final (3) with matching books, or definitive failure resolves them ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Add optional `reconcileRecoveredDispatches` provider capability and controller action for explicit non-financial venue checks with local persistence. Lighter checks owner-null dispatches across trading slots without signing, retrying or acknowledging outcomes; unsupported providers return their local listing. Reject account, network or provider changes while readiness, reconciliation or fallback listing completes; propagate provider failures unchanged ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Expose `utils/positionProtection.assertExpectedPosition` and `utils/guardedHyperLiquidClient.createGuardedHyperLiquidClient` through the existing public wildcard exports for position snapshot validation and operation-local signed-dispatch guards ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Add optional `UpdatePositionTPSLParams.expectedPosition` signed-size and entry-price preconditions. Lighter and HyperLiquid reject stale or invalid protection preconditions with `TPSL_UPDATE_FAILED` before setup and recheck before writes ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Report `positionTpsl` precondition support, receipt identity guarantees and native TP/SL execution types in ready order capabilities ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Return exact `OrderResult.childOrderIds` receipts for settled Lighter full-position TP/SL updates and HyperLiquid updates with complete response- or signed-client-ID-correlated IDs. Removals return an empty receipt; IDs may already be terminal and do not imply resting protection. HyperLiquid position-protection client IDs reserve the subscription program layout; only the fee-reduction flag, not the marker alone, attributes a discount ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Support native Lighter standalone `stop_market`, `stop_limit`, `take_profit_market` and `take_profit_limit` placement and validation for active perpetual markets, preserving trigger levels, execution protection and reduce-only quantities. Report the supported types through `getOrderCapabilities` and expose the fixed venue grid as optional `MarketInfo.priceDecimals` ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Export `LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT` and `LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT` from both `constants` and `constants/lighterConfig` ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Export Lighter trading-slot bounds/count, registration visibility timeout/poll/attempt cap and fill replay capacity from both `constants` and `constants/lighterConfig`: `LIGHTER_MIN_TRADING_API_KEY_INDEX`, `LIGHTER_MAX_TRADING_API_KEY_INDEX`, `LIGHTER_TRADING_API_KEY_COUNT`, `LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS`, `LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS`, `LIGHTER_KEY_REGISTRATION_VISIBILITY_MAX_ATTEMPTS`, and `LIGHTER_FILL_REPLAY_LIMIT` ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Add optional `supportedTriggerOrderTypes` to ready order capabilities and the public `HYPERLIQUID_ORDER_CAPABILITIES` constant. Hyperliquid reports its implemented standalone stop/take-profit placements; missing declarations mean unsupported, independently of strategy and attached TP/SL support ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Add optional `LighterSignerBridge.getRecoverableKeyIndices` and `getStoredKeyIndices` discovery, and an optional `walletAddress` binding on client creation and discovery. Hosts can restore wallet-derived trading keys; Core verifies registered public keys before reuse and skips unrelated candidate ledgers without changing them ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Add optional `onError` callbacks and delivery provider identifiers to order and fill subscriptions, including empty aggregated updates, so clients can identify the provider delivering an update. Lighter currently reports detectable subscription setup failures; other providers may not invoke `onError` ([#10618](https://github.com/MetaMask/core/pull/10618)) - Add optional `PerpsAccountSigner.getChainId`. When set, HyperLiquid user-signed actions (builder fee approval, withdrawals, transfers) are signed for the chain it returns instead of chain 1, so a wallet that only signs for its connected chain does not switch chains ([#10643](https://github.com/MetaMask/core/pull/10643)) ### Changed +- **BREAKING:** Lighter `placeOrder` and `validateOrder` now accept supported native attached TP/SL instead of refusing all attachments; omitted `tpslLinkage` defaults to `order`. Mobile and Extension must gate forwarding on `attachedTpsl` and their rollout policy. Native lifecycle verification remains pending; client adoption is not included ([#10638](https://github.com/MetaMask/core/pull/10638)). +- **BREAKING:** Grouped Lighter signer calls require grouping/count `1/2` or `2/2` with two orders, or `3/3` with three orders. Update `LighterSignCreateGroupedOrdersWireParams` and `LighterWasmCall<'_signCreateGroupedOrders'>` callers to these tuple unions; each order retains its ten-field wire tuple ([#10638](https://github.com/MetaMask/core/pull/10638)). +- **BREAKING:** Add `canceled` to `ScaleOrderChild.state` for accepted children canceled after placement. Consumers exhaustively matching this union must handle the new state. Scale receipts distinguish submitted, accepted and known filled exposure ([#10638](https://github.com/MetaMask/core/pull/10638)). +- **BREAKING:** Add `order_market_unsupported` to `DirectProviderOrderCapabilitiesUnavailableReason` and `OrderCapabilitiesUnavailableReason` for known markets that are inactive or not perpetual and cannot accept native triggers. Consumers exhaustively matching these unions must include the new reason; Scale price-ladder reasons are unchanged ([#10638](https://github.com/MetaMask/core/pull/10638)) +- **BREAKING:** Refuse stray `triggerPrice` on Lighter basic market and limit orders with `ORDER_TRIGGER_PRICE_NOT_SUPPORTED` rather than silently ignoring it. Omit the field or select an explicit trigger order type ([#10638](https://github.com/MetaMask/core/pull/10638)) +- **BREAKING:** Lighter position TP/SL replacement and removal preserve independent partial triggers and trigger limits. Legacy unrecorded trigger-market orders count as protection only on the closing side with exactly the in-lock position quantity and a known restorable time-in-force. Cancel independent orders and pre-upgrade protection whose quantity no longer matches explicitly ([#10638](https://github.com/MetaMask/core/pull/10638)) - **BREAKING:** `OrderFill.pnl` is optional when the venue omits realized PnL. Consumers must preserve missing amounts as unknown when aggregating or displaying fills; only a reported `'0'` is zero ([#10605](https://github.com/MetaMask/core/pull/10605)) +- Treat `lighterAuthConfig.apiKeyIndex` as a preferred slot when key discovery is available; `getRecoverableKeyIndices` takes precedence over `getStoredKeyIndices` ([#10618](https://github.com/MetaMask/core/pull/10618)) +- List local recovery obligations across all trading slots with optional `PerpsRecoveredDispatch.apiKeyIndex` and opaque wallet/network/account/slot-bound IDs. Raw pending dispatches have `acknowledgeable:false`; only resolved stored outcomes can be acknowledged, and legacy IDs must be unique across the account ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Wait for in-flight key selection in recovery readers and retain unfinished TP/SL journals after failed signer initialization ([#10618](https://github.com/MetaMask/core/pull/10618)) - `HyperLiquidProvider.getExchangeClient` initializes the provider's SDK clients before returning the exchange client, so it signs L1 actions with the provider's agent ([#10643](https://github.com/MetaMask/core/pull/10643)) ### Fixed -- Accept Lighter trades that omit position-sign flags, preserve omitted account PnL as unknown, and reject known reductions without realized PnL. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605)) +- Retain each Lighter Scale child cancellation identity and acknowledgement before transport completion so unresolved requests and provider restarts reconcile without signing or sending again. Permit explicit retry only after exact failure or expired, venue-confirmed never-landed evidence; legacy acknowledgement omissions remain uncertain. Reobserve exact active/history overlaps while preserving strict identity and cumulative-fill checks ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Give Lighter Scale placement a dedicated ten-second visibility window and share one ten-second deadline across a group's exact child cancellations ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Wait for exact Lighter Chase cancellation settlement and reconcile retained attempts without signing or sending again, preserving session authority and cumulative fill evidence. Save transport acceptance and exact nonfailed transaction observations under their original owner before nonce retirement, including across session changes. Keep accepted placement and cancellation journals readable when completion reads, parsing or matching fail. Keep conflicting and legacy unknown evidence unresolved; expiry release also requires explicit unacknowledged evidence, a fresh unconsumed slot nonce and exact owned order evidence ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Recognize and cancel exact Lighter Chase children when the venue order nonce differs from the signed transaction nonce, preserving transaction and order ownership checks ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Wait for bounded exact transaction and order settlement after an accepted Lighter native edit, without signing or sending the edit again. Preserve pending outcomes when visibility does not settle within the polling bound ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Wait for bounded exact transaction execution reads before completing Lighter margin mode and collateral updates, without resubmitting pending transactions ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Retire the exact HyperLiquid agent signature when a post-signing guard refuses dispatch, preserving the original wallet attribution, other pending requests and the guard error ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Select and persist the bounded testnet Chase child's final book quote after preparation and nonce acquisition, retaining five-second quote freshness, original duration and dispatch safety checks ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Reject Scale children above Lighter's native base-amount maximum before preview readiness or trading setup, and validate the same limit in durable records. Preserve valid larger aggregates split across bounded children ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Resolve public perps constant and utility subpaths from source during monorepo checks without requiring built outputs; retain separate compiled consumer validation ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Preserve exact replacement child receipts when reconciling committed protection successors; omit unavailable identities and reserve empty receipts for confirmed removal ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Require fresh isolated-position identity and collateral bounds for Lighter margin adjustments, recheck before signing and dispatch, and report success only after exact transaction execution. Preserve unresolved collateral/mode transactions across expiry, nonce advance and restart until exact terminal proof; require exact micro-USDC amounts ([#10638](https://github.com/MetaMask/core/pull/10638)). + +- Keep ordinary Lighter orders unattributed when a live row contradicts a durably rejected Scale child ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Return `ORDER_SCALE_PREVIEW_STALE` as an unsuccessful order result for malformed Scale expectations passed directly to `TradingService`, before fee resolution or placement ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Preserve Lighter Chase cancellation capacity when venue account authority is absent. Persist repeated local stop intent without adding cancellation attempts, retain uncertain dispatched cleanup across restart, and allow exact cleanup after the original authority returns ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Treat confirmed absent and known Premium Lighter accounts as local-only recovery inventories without acknowledgment. Verify account ownership and index before caching or recording recovery authority. Preserve durable obligations and refuse transport, storage, ownership or unverified account-type failures; Premium trading remains unsupported ([#10638](https://github.com/MetaMask/core/pull/10638)). +- Persist guarded Lighter protection removals as non-resumable before dispatch. Recovery reconciles attempted cancellations and preserves surviving orders for explicit review even when saving a later refusal fails; fully settled removals clear without an obsolete warning ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Recheck HyperLiquid position protection after SDK signing and correlate partial SDK acknowledgments by exact signed child IDs. Preserve uncertain outcomes and restore only exact pre-call reduce-only protection when replacement becomes stale after cancellation; report protection loss if restoration fails ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Release only proven-unsent Lighter TP/SL attempts, ownership and nonce reservations after final position rejection, allowing a refreshed update or close while retaining older uncertain attempts ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Refuse position-protection dispatch when the final Lighter account response is ambiguous, belongs to another account or has an invalid position sign, while preserving old protection and allowing a refreshed retry ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Keep newly created Lighter TP/SL ownership across resizing and restart. Bound history reads to one page, reclaim only proven terminal or expired IDs, and retain uncertain IDs when history is unavailable ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Keep Lighter TP/SL recovery obligations for venue-accepted submissions when a later lookup cannot find them, so recovery cannot discard live protection ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Allow removal of proven managed Lighter TP/SL IDs when the position disappears, becomes zero or falls below the size tick after preflight. Require an authoritative account/positions response, preserve unrecorded protection without valid live quantity and side, and retain replacement size/side guards ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Restore canceled HyperLiquid protection after definitive top-level exchange rejection, invalidate rejected agents, preserve venue error details and reconcile partial or lost restoration responses by signed child IDs. Triggered and unknown in-flight children retain their exact IDs and block duplicate restoration. Probe multisig eligibility at each signed protection dispatch without caching readiness ([#10638](https://github.com/MetaMask/core/pull/10638)) +- Reconcile non-selected-slot Lighter protection journals using their original transaction identity without signing follow-up operations. Block new protection changes while attempts remain unsettled, preserve surviving orders for current-key review, and clear previous-slot warnings only after an explicit update settles their recorded orders and obligations ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Reject recovered-dispatch acknowledgment when the selected wallet, network, provider or provider lifetime changes during readiness or acknowledgment, including failed provider/network switch rollbacks and legacy recovery IDs. A stale-context rejection after provider success does not undo acknowledgment in the issuing account; callers must re-list outcomes before acting again ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Refuse stale signer readiness after a wallet switch or bridge reset while setup finishes, preserving unfinished TP/SL recovery visibility ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Fence Lighter financial writes across every local account key slot so migration cannot bypass a pending or quarantined dispatch. Reconcile exact transaction identities under an account-wide write mutex; discovery and key registration share this mutex but bypass other-slot financial obligations. Nonterminal or mismatched transactions stay unresolved and cannot be acknowledged ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Reuse matching Lighter trading keys before allocating a free slot, preserve occupied device keys and wait for new registration visibility. Persist pending registration identity across reconnects and provider restarts so an accepted but invisible key cannot allocate another slot. Remember exact execution observed during reconnect before later expired index absence can authorize another registration ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Deliver Lighter signer setup failures to the current wallet's subscribers. Replay confirmed empty orders and validated newest-first fill history to late subscribers in the same authenticated session; malformed order/trade containers, null order/trade rows and rejected order rows never authorize empty replay; omitted orders or trades containers leave the last snapshot unchanged; throwing error listeners cannot suppress sibling delivery or prevent setup retries ([#10618](https://github.com/MetaMask/core/pull/10618)) +- Accept Lighter trades that omit position-sign flags and preserve omitted account PnL, including closing and flipping fill PnL, as unknown for Activity and late-subscriber replay. Retain side-only fill directions when lifecycle context is ambiguous ([#10605](https://github.com/MetaMask/core/pull/10605), [#10618](https://github.com/MetaMask/core/pull/10618)) - Detect a revoked or expired HyperLiquid agent that the venue answers with "Must deposit before performing actions" ([#10643](https://github.com/MetaMask/core/pull/10643)) - The provider then checks `extraAgents` for the agent that signed the answered request. When it is missing or past its `validUntil`, the write fails with `KEYRING_LOCKED`, the agent is dropped and `onAgentRejected` is called. Requests signed by the main account, or by a wallet of an earlier network or session, are not checked - When the agent is still listed (an account with no funds), or the list cannot be read, the venue error is returned as before diff --git a/packages/perps-controller/README.md b/packages/perps-controller/README.md index d3ad0b4092c..3f9643003fa 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -66,6 +66,27 @@ an asset with an open position, resting order, or active native TWAP schedule, including schedules whose first slice has not filled. Orders in the same mode may increase or reduce the existing position. +## Explicit Lighter margin mode and collateral adjustments + +Lighter accepts explicit `cross` or `isolated` mode with a positive integer +`leverage` on ordinary native orders and attached protection orders for active +perpetual markets. Existing positions and resting, pending or position-tied +orders prevent changing their market mode. Omitted mode preserves existing +selection behavior. Scale and strategy probes continue to reject explicit mode. + +A mode selection must have an exactly identified executed transaction and a +fresh account row showing the selected mode before dependent exposure is signed +or dispatched. Transport acceptance alone does not establish execution. + +`updateMargin` requires an open isolated position and exact micro-USDC precision. +Positive amounts are bounded by fresh available account collateral; negative +amounts require authoritative allocated position margin. The original position +identity and bounds are checked again before signing and final dispatch. The +venue still enforces its own position-risk limits. Success requires exact +transaction execution. Unresolved collateral and mode transactions remain +blocked across restart, nonce advance and expiry until exact terminal proof; +refresh recovered outcomes before explicitly acknowledging them. + ## Signing without a `KeyringController` By default the controller signs through the `KeyringController:*` messenger @@ -112,6 +133,570 @@ a free slot instead: the Lighter API answers "api key not found" for `GET /api/v1/apikeys?account_index=&api_key_index=` when the slot is free. +## Lighter standalone trigger orders + +Lighter supports `stop_market`, `stop_limit`, `take_profit_market` and +`take_profit_limit` through `placeOrder`. Supply a positive `triggerPrice` on +the market's fixed price grid; limit execution also requires `price`. Market +execution applies the caller's slippage protection to the trigger level, and +USD sizing uses that level rather than the current market price. Protection +defaults to 5% when neither `maxSlippageBps` nor `slippage` is supplied. Both trigger +and execution prices must fit the venue's wire range. + +Leave `timeInForce` unset: pending triggers use the signer's default 28-day expiry, +with IOC execution for trigger markets and GTT execution for trigger limits. +`reduceOnly` retains the requested quantity. Below-minimum trigger limits are +refused even when they would currently close the full position; a position can +grow before activation. Attached TP/SL and strategy fields are unsupported on +these standalone orders. + +Position TP/SL replacement and removal recognize Core-created protection by +durable client/venue IDs, even after position growth, shrinkage, provider restart +or trading-key recovery into another slot. IDs are recorded before dispatch in +wallet/account/network/market-scoped storage and retained through uncertain +settlement. Resolved cancellations and exact terminal history prune them; +storage read errors or corrupt records fail closed before protection changes. +Ownership bookkeeping reads at most one recent history page. New records include +the exact client's signed absolute order expiry. When the active book no longer +contains an ID, it can be reclaimed after that expiry plus 30 seconds of clock +slack, even if terminal history is buried or unavailable. Transaction expiry +does not prove an order has expired. Active IDs and unexpired missing IDs remain +owned; legacy records with unknown order expiry need exact terminal history. +Bookkeeping never deep-scans history. Ownership is capped at 256 entries per +wallet/account/network/market. If verified cleanup cannot make room, new +protection creation refuses before dispatch rather than forgetting uncertain +orders. Known live orders can still be explicitly cancelled. Journal +settlement remains strict: an observed accepted submission cannot become +never-landed because of a later missing lookup, and expiry proofs are recomputed +on each reconciliation rather than persisted across restarts. + +Legacy unrecorded reduce-only trigger-market orders on the closing side with +exactly the current position quantity retain their position-protection contract, +including standalone orders placed through `placeOrder`. Protection created +before this ownership upgrade, or after its local records are lost, has no +recorded ownership IDs. If that position has resized, the old trigger remains +legacy and may need explicit cancellation before new protection is established. +Classification reads the current position inside the write lock. Known IOC and GTT wire intents can be +replaced or removed; an unknown time-in-force refuses the change. Independent +partial triggers and trigger limits are preserved. Use explicit cancellation to +remove those orders. Quantity does not determine ownership of Core-created TP/SL. + +Managed Lighter TP/SL removal does not require an integerizable position quantity +after preflight: exact recorded protection IDs can still be cancelled when an +authoritative positions array shows no position, zero size or a size below the +tick. Account-read failures or malformed position envelopes still refuse the +operation. Unrecorded legacy protection still requires a valid live quantity and +side for classification; replacement retains its size and side checks. A supplied +`expectedPosition` must still match for managed removal, including after signing. + +`getOrderCapabilities` reports these types only for active, known perpetual markets. +Capabilities and trigger preflight refresh public metadata and fail closed on +read errors instead of relying on a session's old active-market snapshot. +`MarketInfo.priceDecimals` exposes Lighter's fixed price grid for callers +deriving thresholds. Missing precision is unknown, not a zero-decimal grid. + +## Explicit dispatch reconciliation + +`reviewRecoveryVenue({ providerId })` reads fresh authoritative positions and orders +for one provider, wallet, network and venue account. A ready response carries that +scope and `reviewedAt`; transport, identity, authentication or metadata failures +reject instead of returning an empty account. The read can sign an authentication +token with a matching locally retained registered key. It never registers a key, +allocates a venue slot, signs a financial transaction, starts protection recovery, +or acknowledges or clears an obligation. Missing local read authority requires +reconnecting a matching key. Aggregated callers must name the owning provider; +unsupported providers return `status: 'unsupported'`. + +A manual protection row may include an opaque `recoveryId`. After review, an +explicit `resolveRecoveryProtection({ providerId, recoveryId, symbol, +expectedPosition, takeProfitPrice, stopLossPrice })` selects that exact obligation +and requests new protection (omit both prices for removal). Preserve the ID +verbatim. Lighter reconciles pending original-slot attempts by their exact venue +identity before using a matching current registered key; the original private key +is not required when this evidence is authoritative. The successor only replaces +orders owned by the selected obligation. Its durable source relationship survives +response loss and restart; failed or ambiguous settlement leaves recovery visible. +The result distinguishes `settled`, `unresolved`, and `unsupported`. This is an +explicit financial operation and must never run as part of review or rendering. + +`getRecoveredDispatches()` and `getPendingManualRecoveries()` list local recovery +state. Confirmed account absence and known Premium accounts retain their local rows without signer setup +or venue reconciliation; a wallet with no recorded obligations returns an empty +inventory. Premium trading remains unsupported. Transport failures, corrupt or +unavailable storage, wrong-wallet accounts and unknown account types still reject. +Verified accounts are indexed before venue mutation so nonce-only obligations +remain discoverable after restart even if the venue reports account absence. +Existing protection indices also preserve older account identities. Legacy +nonce-only obligations without an account or protection index become discoverable +when the venue account returns; they are never deleted or reset during absence. +Account capacity is bounded by `LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT`, exported through +`constants` and `constants/lighterConfig`. + +Call `reconcileRecoveredDispatches()` only when the user requests a status check. +This non-financial operation reads venue evidence and updates local ledgers. +It does not initialize a signer, register a key, sign, submit, cancel, acknowledge +or retry an intent. Lighter checks owner-null dispatches across all trading slots; +TP/SL-owned entries and their journals remain pending for their separate recovery +flow. Existing quarantines do not prevent checking other unresolved entries. + +Replace the displayed list with the returned list, including its opaque IDs. +Pending rows and all local-only absent or Premium rows have +`acknowledgeable: false`. Acknowledgment requires a supported current account. +A pending row disappearing can mean its exact transaction was proven absent, +not successful execution. Unknown outcomes remain unknown. Acknowledgment still requires explicit user review and +never grants permission to resubmit an ambiguous intent. Providers without this +capability return their local listing, or an empty list if they have no recovery +state. Aggregation rejects when any provider fails; consumers should retain their +last known rows alongside that error. + +## Lighter fixed partial position protection + +`updatePositionTPSL` accepts a positive `takeProfitSize` or `stopLossSize` for +one trigger, or both sizes for an equal-quantity OCO pair. Explicit quantities +normalize downward on the market size grid without increasing the request. +Values below one tick or above the exact current position are rejected. With +the default native OCO linkage, unequal normalized quantities or only one supplied +size are rejected before mutation. Omitting both sizes retains the existing full-position snapshot +behavior. Explicit sizes never become the venue's dynamic zero-quantity +sentinel. + +Partial replacement cancels only managed or explicitly selected protection, then +proves exact cancellations before creating the replacement. Unrelated independent orders +remain untouched. This leaves a protection gap if creation fails. The durable +operation records the original fixed quantities and client IDs before +cancellation; ambiguous creation is reconciled without replay. + +A proven-unsent operation releases its journal and permits fresh intent. Listing +keeps unsent journals selectable until a fresh update retires them. +Current-key journals with attempted transactions may appear while their +operation is still running; wait for the issuing operation to finish before +choosing recovery. + +After a dispatched cancellation or interrupted creation, recovery never attaches +the saved quantity automatically. Lighter does not expose an immutable position +lifecycle ID, so even an identical-looking position could have been closed and +reopened. Ordinary retries cannot change or dispatch the stored intent. Inspect +the exact recovery and call `resolveRecoveryProtection` with its recovery ID and +a fresh explicit intent for the current position. The capability metadata +reports this boundary, snapshot coverage, and supported pair linkages. +Hyperliquid continues to report independent fixed partial triggers and dynamic +whole-position coverage through its existing implementation. + +Explicit `partialPairLinkage: 'independent'` supports equal or unequal TP/SL +quantities, including an omitted sibling captured from the full current position. +At least one size and both prices are required. The default remains native equal +OCO; explicit OCO is never converted to independent orders. `supportedPairs` +reports the available choices while `pair` retains the provider default. +Independent triggers are submitted TP then SL with separate durable identities. +Uncertainty, rejection, a fired first leg or changed position stops the sibling. +Failures retain accepted coverage and expose observed role-bound +`positionProtection` receipts; saved requests are never automatically replayed. +An omitted `requestedSize` in a receipt means captured full-position coverage. +Venue activation and sibling cancellation are not implied by independent linkage. + +## Native Lighter attached orders + +`placeOrder` accepts a market or limit parent with `takeProfitPrice`, +`stopLossPrice`, or both. Omit attached child sizes. `tpslLinkage` defaults to +`order`. The native OTO or OTOCO transaction contains the opening parent and +zero-size, opposite-side, reduce-only trigger-market children. An explicit +child quantity, position linkage, reduce-only parent, caller-supplied client +ID, or invalid price or size grid is refused before signer setup. IOC and +resting GTC limit parents are supported. The caller's slippage also bounds +child market execution prices. + +`OrderResult.attachedOrderGroup` separates signed client IDs from observed +venue IDs and returns an opaque group handle. `getAttachedOrderGroups` lists +durable local identities across restarts and trading-key changes without signer +setup. Confirmed absent or known Premium accounts retain local groups through +the wallet-scoped recovery account index; listing never acknowledges or replays +them. `reviewAttachedOrderGroups` uses an existing registered local key for +read-only venue authentication, then matches exact signed IDs against bounded +active and bounded older inactive history for missing legs. A preparation-time +cutoff stops reads before the group could have existed. The page/row budget +returns `historyStatus: bounded` with unknown missing legs rather than failing +reviews of other groups. Older journals without a preparation time retain the +explicit budget. Missing orders or linkage stay unknown. A successful +submission reports acceptance, not activation, a fill, or protected quantity. +If a pending `placeAttached:` dispatch blocks writes, call +`reviewAttachedOrderGroups` to refresh exact venue evidence, then +`reconcileRecoveredDispatches` and acknowledge only a resolved outcome. A +pending, non-acknowledgeable dispatch cannot be cleared by acknowledgment alone. + +Pass the exact `groupId` as `cancelOrder.orderId`, with its symbol and +provider, to cancel the owned parent and children. Cancellation rereads exact +IDs after each leg, preserves unrelated triggers and reports success only when +all legs are terminal. Missing identities retain the group for later explicit +review. The group handle never cancels a position or creates replacement +protection. Attached children are excluded from ordinary position TP/SL +replacement/removal. + +Unsigned intent is persisted before signing and transaction identity before +dispatch. Ambiguous acceptance remains quarantined across restart and key +migration. There is no automatic financial replay or attachment to a later +position. Explicitly cancel a prepared group before submitting fresh intent; +uncertain dispatches also require the existing exact-transaction reconciliation +and acknowledgment flow. Exact failure observed by review remains durable until +nonce-ledger retirement succeeds, including retries after storage failure. When +the exact hash stays absent but the nonce advanced, explicit review can +transfer fresh complete exact-leg observations into an acknowledgeable +succeeded outcome. Saved IDs alone never authorize this transfer. The group +remains owned; acknowledgment permits later intentional writes and exact +cancellation without replay. Reconciliation persists exact failed, expired or +nonce-consumed non-acceptance before retiring the nonce evidence; explicit +group cancellation can then abandon that intent without a cancellation +signature or replay of the grouped order. Signer setup and authentication still +apply. Nonce advance before signed expiry remains ambiguous. Abandonment +rereads exact transaction and bounded order history, and refuses recorded or +freshly correlated legs. Missing history alone never proves non-acceptance. Up +to 64 groups are retained per account. Only canceled groups or `completed` +groups with all legs exactly correlated as terminal can be evicted. Review +reads one bounded snapshot per market and skips unchanged persistence. Terminal +groups normally return local identities without new order observations or +linkage; a retained dispatch ledger keeps them eligible for fresh review and +settlement. + +Mobile and Extension must gate attached forwarding on `attachedTpsl` plus their +own rollout policy. This package change does not adopt the feature in either +client. `attachedTpsl.lifecycleVerification` remains `pending`. Native +activation, partial-parent-fill coverage, and automatic parent/child +cancellation guarantees require venue execution evidence. The current +capability only describes the implemented grouped submission, read review and +explicit exact-ID cancellation. + +## Durable Lighter Scale groups + +Lighter advertises Scale as a provider source capability for active markets. +Preview, validation and placement share fixed-grid ladder normalization and +reject collapsed price ticks, underfunded rungs and unsupported execution +fields. Sizes conserve whole base lots; USD sizing bounds the sum of every +rung's limit notional. Margin and reduce-only reservations are checked before +placement and again before each send. Caller-supplied slippage, trigger, +attached-protection, time-in-force, full-close and other strategy fields are +rejected rather than ignored. + +`getScalePriceLadder` accepts optional `sizing` with either an exact base `size` +or a maximum quote `usdAmount`, plus optional `skew`. Lighter returns +`sizingPreview` with exact per-rung sizes, totals, size precision and the venue's +maker minimums. Preview uses the placement builder without account reads, +key setup or signing. It rounds base size down and never exceeds the quote +budget; account collateral and reservations still require placement validation. +Omitting `sizing` preserves the price-only result. Providers without sizing +support leave `sizingPreview` absent, so clients must check before using it. + +Each group persists wallet, network, account, trading slot, immutable rung +intent and signed dispatch identity before transport. Placement polls through +bounded venue visibility lag and stops after unresolved acceptance; +reconnection never resumes or replays it. `getScaleOrderGroups()` lists local +ownership, including groups without any venue order ID. +`reviewScaleOrderGroups({ providerId })` reconciles with registered read +authority and may sign authentication, but does not register a trading key or +send financial transactions. Aggregated review requires an explicit provider. +Controller calls reject changes to the issuing account, network or provider +while awaiting readiness or results. + +`OrderResult.submittedSize` includes attempted children whose acceptance is +unknown. `acceptedSize` and `weightedAverageLimitPrice` are omitted while any +attempted child remains uncertain. `acceptedChildren` retains confirmed +accepted children, including canceled children; `childOrderIds` contains +resting children only. Filled quantities require authoritative venue data; +limit prices never stand in for execution prices. Wholly unknown groups never +create synthetic `Order` rows. Real venue rows that match the persisted intent +carry `strategyGroupId`. A live row that contradicts a durably rejected child +remains an ordinary order without that group's attribution. + +Cancel with `{ orderType: 'scale', orderId: groupId, symbol }`. Only exact +group children are canceled, using the currently registered trading slot even +when placement belonged to a previous slot. Unknown dispatches remain protected +until exact terminal transaction proof, or durable expiry proof followed by +fresh absent transaction and order history, allows settlement. Explicit group +review can retire expired absent children without canceling accepted siblings. +Fresh exact rows can recover a pending child's receipt; recovered outcomes +still require explicit acknowledgment before another financial write. Proven +terminal cancellation never initializes a trading signer. Order history +traversal is bounded and incomplete or inconsistent history fails closed. The +local journal retains at most 64 groups and only reclaims proven-terminal +groups. Local disk loss cannot reconstruct an unknown group's identity; +consumers must preserve the configured durable storage. + +Clients must gate availability on `getOrderCapabilities` and route review and +cancellation to the owning provider. An HTTP acknowledgment or group ID never +proves a fill or successful cancellation. + +## Lighter native TWAP preparation + +Lighter TWAP remains unavailable in `getOrderCapabilities`. Default placement refuses +before signer setup. `getTwapOrders` rejects rather than reporting an empty +inventory, and `cancelOrder` with `orderType: 'twap'` refuses before signing. +A generic cancel transaction acknowledgment cannot establish that a native +schedule has terminated or that no later slices will execute. + +The `utils/lighterTwap` preparation primitive validates exact amount and price +bounds, rounds price protection inward by side, converts whole minutes to a +future millisecond expiry, and rejects randomization. It does not sign, submit, +persist an operation, or expose a supported strategy. It does not impose the +Hyperliquid duration rules or claim that the venue accepts every prepared value. +The caller must supply a millisecond clock and current authoritative market +precision and reference price before any future lifecycle integration. + +Enabling TWAP requires authoritative cumulative parent-fill semantics, complete +child-fill attribution and terminal cancellation evidence. Persisted ownership, +restart reconciliation and uncertain-dispatch recovery must then be integrated +and validated before enabling placement. Reducing a position to zero is not +termination: Lighter documents that reduce-only schedules keep attempting slices +until expiry. + +## Gated Lighter native TWAP lifecycle + +The provider's default-off `nativeTwapTestnetProbe` constructor option enables +bounded source-validation placement and cancellation on testnet. It rejects +mainnet construction, requires explicit and independently observed 1x leverage, +exact size within the venue's parent minimums and a maximum 20 USD initial +protected notional, and refuses unsupported strategy fields. Normal controller +construction does not enable this option. `getOrderCapabilities` and product +`getTwapOrders` remain unavailable pending native mapping proof. + +Before signing a native schedule, a separate durable journal records immutable +wallet, network, account, original key slot, market, exact intent and client ID. +Signed nonce, hash and transaction expiry are recorded separately from schedule +expiry, and dispatch attempt is persisted before transport. Lost responses and +restarts retain obligations without replaying CreateOrder. An unresolved owned +schedule prevents another on the same market. The journal is bounded to 64 +records and 16 cancellation attempts per record; it does not silently prune +unresolved or historical records. + +`getNativeTwapObservations()` uses existing registered read authority, never key +registration or financial dispatch. It reports exact parent and child rows, +unaggregated child trades, matching transaction outcomes, candidate normalized +fills and explicit mapping issues. Parent identity includes account, market, +client ID, side, size, price, expiry and nonce. Pagination must exhaust, repeated +cursors fail, snapshots must remain stable, and parent/child/trade quantities +must agree. A failed read or an omitted parent is uncertainty, not an empty +schedule inventory or a completed order. Unrelated orders are excluded from +parent-child reconciliation. + +`accountOrders` has a documented retention window: last 10K active orders or +last 1K inactive orders within 24 hours. History scans retain raw evidence when +an exact row is absent, but canonical projection still requires a stable exact +parent reread. Missing retained history therefore remains an explicit obligation. +The current native mapping gate prevents terminal observations from releasing +cleanup obligations. The settlement code additionally requires a matching +executed cancellation for canceled parents and elapsed expiry for expired +parents. Disconnecting the provider never completes or deletes schedules. + +Native slice minimums, cumulative parent/child semantics, terminal timestamps +and cancellation race ordering still require testnet evidence. +Candidate observations and unit fixtures are not venue proof. The constructor +probe option is not exposed as a production feature flag or controller action. + +## Lighter post-only limits + +Ordinary `limit` orders accept `timeInForce: 'ALO'`. Validation and placement +require active market metadata, native price/size grids and maker minimums. +Unsupported strategy, attached protection and caller client-ID fields are +rejected before signer setup. Fresh public native book reads reject a crossing +price before setup and again inside the serialized write section. A book read +older than five seconds fails closed. Native TIF 2 is the final venue guard if +the book changes after local validation; GTT and IOC behavior is unchanged. + +A successful ordinary placement receipt means the transaction was submitted; +it does not assert that a post-only order rested or filled. A racing book can +still produce native `canceled-post-only` history. Reconcile exact order state +through ordinary venue reads. Response loss retains the exact client handle in +the failure receipt and the inherited durable nonce/transaction journal blocks +replay across restart until authoritative recovery. No automatic retry is added. +This transport support does not advertise Chase or establish its venue proof. + +## Bounded Lighter Chase lifecycle + +The default leaves Chase unavailable. Set +`clientConfig.providerCredentials.lighter.chaseTestnetProbe: true` to enable the +bounded probe on the actual testnet provider with an injected signer. Controller +registration omits this setting on mainnet; direct mainnet +construction rejects it. +Enabled testnet capabilities include Chase. Production remains unavailable; +client integration and live venue acceptance are still required for rollout. + +The probe uses the existing `orderType: 'chase'` contract with exact base size, +explicit existing 1x leverage, a 20 USD aggregate notional ceiling, an interval +of at least 1,000 ms, duration from one interval through 300,000 ms, at most 20 +reprices, and an adverse distance strictly between 0 and 10,000 bps. Defaults are +15,000 ms interval, 60,000 ms duration, one reprice and 100 bps distance. It starts +from a flat account with no unrelated open orders or exposure. Every replacement +requires account position quantity to match the exact cumulative owned fills, +plus no outstanding prior child. Existing position value plus replacement +notional must still fit the same ceiling. Native minimums apply to each remaining +child; a remainder below minimum stops rather than enlarging the order. + +Probe transport currently supports opening orders only; reduce-only, USD sizing, +attached protection and other strategy fields remain refused at the probe +boundary. The dedicated coordinator retains reduce-only intent in its internal +contract, but no product or probe support is claimed for that route. No automatic +leverage change, new slot allocation policy, key replacement, fee or PnL invention +is introduced. + +The native public order book supplies individual owner-tagged orders. Quotes +remove every same-side account order, preserve opposing liquidity, and use the +market's fixed price tick. Missing external liquidity, stale responses, malformed +identity or a crossed book stop financial continuation. Each replacement uses +newly read quotes and native post-only TIF 2. + +A durable journal binds wallet, network, account, original key slot, market, +immutable budget and session handle. Each exact child is persisted before +signing, its hash/nonce/expiry before dispatch, and attempted state before send. +A serial tick cannot replace a child until exact terminal order state, exhaustive +unaggregated fill history and a stable reread agree. Exact terminal child state +settles cleanup even when a fill beats its cancel transaction. Cancel transaction +outcomes provide attribution and retire definitively failed attempts for explicit +retry. Fills observed during cancellation reduce the next size. Acknowledgments +and missing rows never prove termination. An attempted placement absent after +signed expiry plus clock slack can settle only with definitive transaction and +exact-order evidence; acknowledged or previously visible placements remain owned. +There are at most 64 retained sessions, 21 children per session and 16 cancel +attempts per child; records are never silently pruned. + +`getChaseOrders()` returns provider-bound public state, or an empty list for an +unbound or recordless wallet, including with the probe off. If current venue +discovery reports no account, previously verified wallet/network journals remain +visible. Suspension retains their pending cleanup locally without enabling venue +reads, signing or registration. Repeated local suspension and cancellation preserve +the child's cancellation capacity and existing dispatch identities, including across +provider restart. Cleanup remains `termination_pending` with an explanatory native +record error until the original account and key authority can reconcile it. +`PerpsController:getChaseOrderOwnership({handle, providerId, owner?})` reads one +exact stable handle from validated local storage. `available` includes its original +wallet, provider, network, account and trading-key slot, every durable child in order, +placement/cancellation phases and public transaction identities, and each child's +last exact persisted venue ID, terminal flag and decimal fill quantities. Superseded +children remain included. An absent observation leaves attempted or acknowledged +placement unresolved. These observations are not a fresh venue reconciliation. +The optional `owner` binds a repeat read to the full original identity. + +This read works with the probe off, without a signer and after venue authority +has disappeared. It does not bind/rebuild transport, allocate keys, sign, resume, +cancel or write storage. Prepared/signed attempts retain their recorded phases. +Account, network, provider, key/session or controller lifetime changes across awaits +reject the read. Missing handles return `unavailable` with `not_found`; a mismatched +expected owner returns `owner_mismatch`. Missing storage never becomes an available +empty inventory, and malformed/ambiguous records reject. Returned IDs grant no +cleanup authority under another wallet, account, network or key. + +Pass the exact `OrderResult.orderId` as `handle`, preserve all opaque IDs and +reconcile each returned child independently before explicit cleanup for that handle. +Aggregated mode routes only the requested `providerId`. HyperLiquid reports +`unsupported` for this durable read and retains its existing `getChaseOrders()` +behavior. Full Lighter Chase readiness still requires client integration and venue +proof; the default-off/testnet gates remain unchanged. TWAP remains separate: +Lighter's public TWAP lifecycle read refuses support until authoritative parent fills +and terminal semantics are established. Local children do not establish a complete +TWAP schedule. + +`PerpsController:reconcileChaseOrderCancellation({handle, providerId, owner, +clientOrderId, cancellation: {nonce, txHash, expiresAt}})` reconciles the latest +already attempted cancellation of a stopped handle. Preserve the full original +owner and exact latest child/cancellation transaction identity from durable history. +Lighter observes venue evidence and persists management settlement under the existing +owner lock and session fence. The service receives only observation, clock and +session-check functions, so it cannot allocate a nonce, sign or send another +cancellation even when an acknowledged cancellation becomes failed and the child +remains open. Existing registered read authority may create an authentication client +and read nonce metadata, but it never registers a key or submits a transaction. + +A proven terminal handle returns `settled` with its public `order`; uncertain +visibility, missing acknowledgement or failed/lost venue replies return `unresolved`. +A missing original cancellation, active handle, wrong original identity or malformed +inventory rejects before venue observation. Providers without this operation return +`unsupported`; aggregated routing never substitutes ordinary cancellation. A lost +public reconciliation reply remains unresolved and grants no authority to replay +`cancelOrder`. Ordinary explicit cancellation retains its existing behavior. + +`getNativeChaseRecords()` exposes exact local cleanup identities for diagnostics. +`cancelOrder({orderType: 'chase', orderId: handle, symbol, providerId: 'lighter'})` +explicitly terminates an owned handle. A wrong-symbol request leaves its active +duration cleanup intact. Confirmed explicit cleanup succeeds even when the +record retains an earlier failure cause and terminal status. +`suspendChaseOrders()` interrupts starts waiting on setup as well as in-flight +and scheduled continuation before attempting exact cleanup. This Lighter probe +cancels the last child on backgrounding and duration, repricing or distance limits. +Confirmed cleanup reports `canceled` with no resting order ID; the native record +retains the first cause in `stopReason`. Unknown cleanup reports +`termination_pending`. HyperLiquid suspension leaves its child resting. The +Lighter timer uses the absolute duration deadline; network latency and suspended +execution can delay venue cleanup. Account changes and +provider teardown interrupt synchronously. Reconnect exposes interrupted records +as `termination_pending` and never restarts the financial loop. Unknown cancel or +lost-response ownership remains visible, including across process restart and +original-slot changes; cleanup requires the original available authority and +existing recovery-ledger conditions. + +Exact order lookup has the venue's limited retention window. Missing retained +history or unsafe numeric IDs fail closed and may require separate manual +recovery; they do not authorize a replacement. All lifecycle checks above are +source behavior. Live post-only crossing, fill/cancel races and bounded reprice +behavior require venue acceptance checks. + +`utils/lighterTwapReconciliation.reconcileLighterTwapObservation` reconciles +complete native parent/child rows and unaggregated trades against persisted +signed intent. `identifyLighterTwapParent` validates immutable parent ownership +without requiring fill reconciliation, so incomplete history does not block +exact cancellation. Neither utility proves venue termination. Numeric and +string trade IDs must agree; trade provenance allows 30 seconds of clock skew. + +Native TWAP and Chase probes share one unresolved-account exclusion, including +across provider instances and restarts. Unverified TWAP terminal observations +retain that exclusion. The provider class is not a package export, controller +registration omits probe constructor options, and observation collection needs +a direct Lighter provider rather than the aggregated provider. Probe callers +must supply a development integration; these are not enabled product features. + ## Contributing -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). +This package is part of a monorepo. Instructions for contributing can be found +in the [monorepo README](https://github.com/MetaMask/core#readme). + +### Native resting-order edits + +Lighter `editOrder` modifies the same exact venue order with transaction type 17. +It supports price or size changes to verified unfilled ordinary active perpetual +limits with GTC or post-only time-in-force. Side, type, time-in-force, reduce-only +and expiry stay fixed. Trigger, linked protection and durably recorded Scale, +Chase and position-protection children are refused. Partially filled orders are +also refused because the pinned signer proof does not establish total-versus- +remaining amount semantics for those orders. + +Pass unsafe int64 venue IDs as canonical decimal strings. Core decodes raw venue +and signed JSON with ordinary string parsing, preserving integer identities +without Node reviver extensions or rewriting the signed transaction. It checks +the refreshed market grid, minimums and native integer widths before signing, +and rereads the target after signing. The pinned seven-position +`_signModifyOrder` tuple has no order-version or resting-expiry argument, so a +fill or cancel can still race after that final read. + +`OrderResult.orderEdit` reports requested price and size, a durable status and an +optional exact same-order observation. `success:true` requires exact transaction +execution and an unfilled open observation with the requested price, size and +remaining size. HTTP acceptance, unchanged fields, missing rows, lookup failures +and partial-fill races remain pending. An exactly observed filled or canceled +order after proved execution is terminal with `success:false`; it is not an edit +success or a fabricated fill receipt. No cancel/re-place fallback runs. + +Core persists unsigned intent before signing and records the exact nonce, hash, +expiry and original wallet/network/account/key before dispatch. It never stores +signed payloads or keys in the edit journal. After interruption or restart, +calling `editOrder` on that ID reconciles the retained intent using the original +registered local key before financial setup. The call returns that prior outcome +without submitting another edit, including when the new desired fields differ. +Pending records cannot be overwritten by retries. Exact failed or proved-unsent +attempts, and unaccepted exact hashes confirmed absent after signed expiry plus +clock slack and an exact target reread, permit a subsequent explicit retry. +Earlier acceptance or execution proof remains pending through later lookup loss. + +This Core contract does not establish venue edit effects, Mobile bridge support +or delivered client parity. Mobile adoption and testnet same-order price/size +lifecycle proof remain separate validation work. + +The Chase probe accepts Mobile's explicit `usdAmount` only when its downward-grid +size agrees with the captured current price and fresh venue quote. The USD amount +becomes the durable aggregate budget for original and replacement children, within +the 20 USD limit. Preparation is repeated after signer readiness, and existing +late dispatch, 1x, opening-only, fill and child-ownership checks remain required. diff --git a/packages/perps-controller/jest.config.cjs b/packages/perps-controller/jest.config.cjs index 76070d8b174..d4899af9415 100644 --- a/packages/perps-controller/jest.config.cjs +++ b/packages/perps-controller/jest.config.cjs @@ -26,6 +26,31 @@ module.exports = { }, }), + // Node 22 cannot require the SDK's ESM entrypoint through Jest. Compile the + // real SDK and its ESM dependencies with the existing TypeScript transformer. + transform: { + ...baseConfig.transform, + '^.+\\.js$': [ + 'ts-jest', + { + // ts-jest follows Jest's supportsStaticESM flag: CommonJS on Node 22, + // native ESM when Node 24 loads the SDK through require(esm). + useESM: true, + tsconfig: { + allowJs: true, + checkJs: false, + module: 'ESNext', + moduleResolution: 'Node', + verbatimModuleSyntax: false, + ignoreDeprecations: '6.0', + }, + }, + ], + }, + transformIgnorePatterns: [ + '/node_modules/(?!@nktkas/(?:hyperliquid|rews)/|@noble/hashes/)', + ], + // Coverage is collected from real source files. Barrel files are excluded // because they only re-export the tested modules. // Applied after merge to fully replace (not concat) the base array. diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 0205b106afb..e5356b2fda6 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -86,7 +86,7 @@ export type PerpsControllerGetActiveProviderOrNullAction = { }; /** - * Get strategy capabilities through the active provider route used by order + * Get order capabilities through the active provider route used by order * placement. The query waits for in-flight initialization and reports an * explicit unavailable status when no provider route can answer reliably. * @@ -114,9 +114,9 @@ export type PerpsControllerGetMarginModeLockAction = { /** * Build a Scale price ladder using the active provider's venue rules. * - * @param params - Market, ladder bounds, count, and optional explicit route. - * @returns Provider-normalized prices or a typed unavailable result. - * @throws When the provider cannot normalize the requested ladder. + * @param params - Market, ladder bounds, count, optional sizing and explicit route. + * @returns Provider-normalized prices with sizingPreview when supported, or a typed unavailable result. + * @throws When bounds or sizing violate the provider's venue rules. */ export type PerpsControllerGetScalePriceLadderAction = { type: `PerpsController:getScalePriceLadder`; @@ -181,8 +181,37 @@ export type PerpsControllerGetChaseOrdersAction = { }; /** - * Stop Chase repricing for app backgrounding without cancelling the current - * resting children. + * Observe durable ownership for an exact Chase handle through one provider. + * Starts no signing, transport, continuation, cancellation or durable writes. + * Original owner/child IDs are observations, never permission for cleanup in + * a different context. Providers without durable history report unsupported. + * + * @param input - Opaque handle, explicit route and optional original owner. + * @returns Complete local child history, unsupported, or explicit absence. + * @throws On corrupt storage or account/network/provider/lifetime changes. + */ +export type PerpsControllerGetChaseOrderOwnershipAction = { + type: `PerpsController:getChaseOrderOwnership`; + handler: PerpsController['getChaseOrderOwnership']; +}; + +/** + * Reconcile one retained exact Chase cancellation without any new cancellation. + * Unsupported providers are never routed to ordinary financial cancellation. + * + * @param input - Original handle, owner, child and transaction identity. + * @returns Proven terminal state, unresolved cleanup or unsupported capability. + * @throws On invalid ownership or stale provider/account/network lifetime. + */ +export type PerpsControllerReconcileChaseOrderCancellationAction = { + type: `PerpsController:reconcileChaseOrderCancellation`; + handler: PerpsController['reconcileChaseOrderCancellation']; +}; + +/** + * Stop Chase repricing for app backgrounding. HyperLiquid leaves current + * children resting. The bounded Lighter probe attempts exact cancellation and + * reports canceled or termination_pending rather than a resting status. * * @returns Chase snapshots after suspension. * @throws If an aggregated provider cannot suspend every active venue. Other @@ -404,7 +433,10 @@ export type PerpsControllerGetOrderFillsAction = { /** * List TP/SL protection changes the active provider parked for * explicit manual re-establishment. Providers without durable - * settlement state return an empty list. + * settlement state return an empty list. Lighter includes previous-key and + * pre-initialization journals for the selected wallet/account, waits for any + * in-flight key selection and keeps failed-setup obligations visible. A + * current-key update does not clear an earlier key's unfinished journal. * * @returns Pending manual-recovery entries. */ @@ -413,24 +445,120 @@ export type PerpsControllerGetPendingManualRecoveriesAction = { handler: PerpsController['getPendingManualRecoveries']; }; +/** + * Review native attached lifecycle through provider-owned read authority. + * + * @returns Exact venue identities without financial replay or key registration. + */ +export type PerpsControllerReviewAttachedOrderGroupsAction = { + type: `PerpsController:reviewAttachedOrderGroups`; + handler: PerpsController['reviewAttachedOrderGroups']; +}; + +/** + * List durable attached identities for the selected provider and account. + * + * @returns Stored intent without venue writes, signer setup or automatic replay. + */ +export type PerpsControllerGetAttachedOrderGroupsAction = { + type: `PerpsController:getAttachedOrderGroups`; + handler: PerpsController['getAttachedOrderGroups']; +}; + /** * 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. + * durable dispatch state return an empty list. Lighter scans the bounded + * local trading-slot range, including skipped keys, without signing or + * changing quarantine. After controller initialization, this works before + * Lighter signer initialization and after signer setup failure; an in-flight + * selection settles first. Preserve opaque recovery IDs exactly. Raw pending + * dispatches report unknown with acknowledgeable:false and cannot be cleared + * by acknowledgment, including current-session in-flight submissions. Listing + * starts no background reconciliation. A later financial action re-checks + * authoritative state for all account slots before dispatch and remains + * blocked while an obligation is unresolved. + * + * @returns Pending recovered-dispatch outcomes, with their original key slot + * when supplied by the provider. */ export type PerpsControllerGetRecoveredDispatchesAction = { type: `PerpsController:getRecoveredDispatches`; handler: PerpsController['getRecoveredDispatches']; }; +/** + * Execute an explicit successor for one selected durable protection obligation. + * + * @param params - Owning provider, opaque source ID and new protection intent. + * @returns Settled, unresolved or unsupported recovery result. + */ +export type PerpsControllerResolveRecoveryProtectionAction = { + type: `PerpsController:resolveRecoveryProtection`; + handler: PerpsController['resolveRecoveryProtection']; +}; + +/** + * Read durable Scale groups without placing or replaying any child. + * @returns Groups belonging to the issuing controller context. + */ +export type PerpsControllerGetScaleOrderGroupsAction = { + type: `PerpsController:getScaleOrderGroups`; + handler: PerpsController['getScaleOrderGroups']; +}; + +/** + * Reconcile durable Scale groups for an explicitly selected provider. + * @param params - Issuing provider route. + * @param params.providerId - Provider to review. + * @returns Fresh durable groups; never replays placement. + */ +export type PerpsControllerReviewScaleOrderGroupsAction = { + type: `PerpsController:reviewScaleOrderGroups`; + handler: PerpsController['reviewScaleOrderGroups']; +}; + +/** + * Review fresh venue positions and orders for one issuing provider context. + * Auth signing may be required; registration and financial writes are forbidden. + * + * @param params - Owning provider route. + * @param params.providerId - Explicit provider identifier. + * @returns Strict venue review or honest unsupported capability. + */ +export type PerpsControllerReviewRecoveryVenueAction = { + type: `PerpsController:reviewRecoveryVenue`; + handler: PerpsController['reviewRecoveryVenue']; +}; + +/** + * Explicit non-financial reconciliation with local persistence. Never signs, + * retries or acknowledges dispatches. Unsupported providers return their local + * listed state, or an empty list when neither capability is available. + * Rejects account, network or provider changes while controller readiness, + * reconciliation or fallback listing completes. Provider rejections propagate + * unchanged. + * + * @returns Newly scoped pending and recovered dispatches. + */ +export type PerpsControllerReconcileRecoveredDispatchesAction = { + type: `PerpsController:reconcileRecoveredDispatches`; + handler: PerpsController['reconcileRecoveredDispatches']; +}; + /** * 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}. + * durable dispatch state or the id no longer matches. Lighter scopes IDs to + * wallet/network/account/key and accepts legacy IDs only when unambiguous + * across local account ledgers. Acknowledgment removes one stored outcome, + * never an unresolved dispatch or a TP/SL journal, and authorizes no retry. + * Rejects account, network, provider or lifecycle changes while readiness or + * acknowledgment completes. A stale rejection after provider success does + * not undo removal in the issuing account. Re-list outcomes before acting + * again. Provider rejections propagate unchanged. + * + * @param recoveryId - Opaque stable id from {@link getRecoveredDispatches}. * @returns Resolves when the outcome is acknowledged. */ export type PerpsControllerAcknowledgeRecoveredDispatchAction = { @@ -1477,6 +1605,8 @@ export type PerpsControllerMethodActions = | PerpsControllerCancelOrderAction | PerpsControllerGetTwapOrdersAction | PerpsControllerGetChaseOrdersAction + | PerpsControllerGetChaseOrderOwnershipAction + | PerpsControllerReconcileChaseOrderCancellationAction | PerpsControllerSuspendChaseOrdersAction | PerpsControllerCancelOrdersAction | PerpsControllerClosePositionAction @@ -1496,7 +1626,14 @@ export type PerpsControllerMethodActions = | PerpsControllerGetPositionsAction | PerpsControllerGetOrderFillsAction | PerpsControllerGetPendingManualRecoveriesAction + | PerpsControllerReviewAttachedOrderGroupsAction + | PerpsControllerGetAttachedOrderGroupsAction | PerpsControllerGetRecoveredDispatchesAction + | PerpsControllerResolveRecoveryProtectionAction + | PerpsControllerGetScaleOrderGroupsAction + | PerpsControllerReviewScaleOrderGroupsAction + | PerpsControllerReviewRecoveryVenueAction + | PerpsControllerReconcileRecoveredDispatchesAction | PerpsControllerAcknowledgeRecoveredDispatchAction | PerpsControllerGetOrdersAction | PerpsControllerGetOpenOrdersAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 6a019fdda16..4e173760cc4 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -81,6 +81,10 @@ import type { CancelOrdersParams, CancelOrdersResult, ChaseOrder, + GetChaseOrderOwnershipParams, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, + PerpsChaseOrderOwnership, ChaseOrderMaxDistanceReached, ClosePositionParams, ClosePositionsParams, @@ -126,6 +130,11 @@ import type { ScalePriceLadderUnavailableReason, PerpsPendingManualRecovery, PerpsRecoveredDispatch, + AttachedOrderGroup, + PerpsRecoveryVenueReview, + ScaleOrderGroup, + ResolveRecoveryProtectionParams, + PerpsRecoveryProtectionResult, Position, SubscribeAccountParams, SubscribeCandlesParams, @@ -179,6 +188,7 @@ import { import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils.js'; import { ensureError } from './utils/errorUtils.js'; import { parseAssetName } from './utils/hyperLiquidAdapter.js'; +import { captureScaleOrderParams } from './utils/lighterScaleOrders.js'; import { clonePerpsMarketData, compileMarketPattern, @@ -928,6 +938,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'getCachedMarketDataForActiveProvider', 'getCachedUserDataForActiveProvider', 'getChaseOrders', + 'getChaseOrderOwnership', + 'reconcileChaseOrderCancellation', 'getTwapOrders', 'getUserDataSnapshot', 'getCurrentNetwork', @@ -952,6 +964,13 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPositions', 'getSelectedOrderType', 'getRecoveredDispatches', + 'getAttachedOrderGroups', + 'reviewAttachedOrderGroups', + 'getScaleOrderGroups', + 'reviewScaleOrderGroups', + 'reconcileRecoveredDispatches', + 'reviewRecoveryVenue', + 'resolveRecoveryProtection', 'acknowledgeRecoveredDispatch', 'getTradeConfiguration', 'getRecentlyViewedMarkets', @@ -1309,6 +1328,7 @@ export class PerpsController extends BaseController< // preload-scoped account handler is torn down on disconnect, so it cannot // carry this. const forgetRegisteredTradingAddresses = (): void => { + this.#selectedAccountGeneration += 1; this.#rewardsIntegrationService.resetRegisteredTradingAddresses(); // Clearing alone only guarantees the *next preview* re-registers. An // order submitted straight after a switch, with no preview in between, @@ -2452,6 +2472,7 @@ export class PerpsController extends BaseController< protected registerLighterProvider( LighterProviderClass: new (opts: { isTestnet: boolean; + chaseTestnetProbe?: boolean; platformDependencies: PerpsPlatformDependencies; messenger: PerpsControllerMessenger; lighterAuthConfig: LighterAuthConfig; @@ -2466,6 +2487,9 @@ export class PerpsController extends BaseController< this.#options.clientConfig?.providerCredentials?.lighter ?? {}; const lighterProvider = new LighterProviderClass({ isTestnet: lighterIsTestnet, + ...(lighterIsTestnet && lighter.chaseTestnetProbe === true + ? { chaseTestnetProbe: true } + : {}), platformDependencies: this.#options.infrastructure, messenger: this.messenger, signerBridge: lighter.signerBridge, @@ -2838,7 +2862,7 @@ export class PerpsController extends BaseController< } /** - * Get strategy capabilities through the active provider route used by order + * Get order capabilities through the active provider route used by order * placement. The query waits for in-flight initialization and reports an * explicit unavailable status when no provider route can answer reliably. * @@ -2974,13 +2998,17 @@ export class PerpsController extends BaseController< /** * Build a Scale price ladder using the active provider's venue rules. * - * @param params - Market, ladder bounds, count, and optional explicit route. - * @returns Provider-normalized prices or a typed unavailable result. - * @throws When the provider cannot normalize the requested ladder. + * @param params - Market, ladder bounds, count, optional sizing and explicit route. + * @returns Provider-normalized prices with sizingPreview when supported, or a typed unavailable result. + * @throws When bounds or sizing violate the provider's venue rules. */ async getScalePriceLadder( params: GetScalePriceLadderParams, ): Promise { + const intent = { + ...params, + sizing: params.sizing === undefined ? undefined : { ...params.sizing }, + }; let activeProvider: PerpsProvider; try { activeProvider = await this.#getActiveProviderWhenReady(); @@ -2991,13 +3019,13 @@ export class PerpsController extends BaseController< }); return this.#getUnavailableScalePriceLadder( 'provider_unavailable', - params.providerId, + intent.providerId, ); } const resolvedProviderId = - params.providerId ?? this.#getDirectProviderId(activeProvider); - if (this.#hasConflictingProviderRoute(params.providerId, activeProvider)) { + intent.providerId ?? this.#getDirectProviderId(activeProvider); + if (this.#hasConflictingProviderRoute(intent.providerId, activeProvider)) { return this.#getUnavailableScalePriceLadder( 'provider_not_routable', resolvedProviderId, @@ -3010,7 +3038,7 @@ export class PerpsController extends BaseController< ); } - const result = await activeProvider.getScalePriceLadder(params); + const result = await activeProvider.getScalePriceLadder(intent); if ( result.status === 'unavailable' && result.providerId === undefined && @@ -3113,15 +3141,25 @@ export class PerpsController extends BaseController< * @returns The order result with order ID and status. */ async placeOrder(params: OrderParams): Promise { + let capturedParams: OrderParams; + try { + capturedParams = captureScaleOrderParams(params); + } catch (error) { + return { + success: false, + error: ensureError(error, 'PerpsController.captureScaleOrderParams') + .message, + }; + } const provider = await this.#resolveRoutedOrderProvider({ - orderType: params.orderType, - providerId: params.providerId, + orderType: capturedParams.orderType, + providerId: capturedParams.providerId, }); this.#ensureTradingServiceDeps(); const result = await this.#tradingService.placeOrder({ provider, - params, + params: capturedParams, context: this.#createServiceContext('placeOrder', { saveTradeConfiguration: (symbol: string, leverage: number) => this.saveTradeConfiguration(symbol, leverage), @@ -3131,7 +3169,7 @@ export class PerpsController extends BaseController< }); if (result.success) { - this.clearPendingTradeConfiguration(params.symbol); + this.clearPendingTradeConfiguration(capturedParams.symbol); } return result; @@ -3207,8 +3245,152 @@ export class PerpsController extends BaseController< } /** - * Stop Chase repricing for app backgrounding without cancelling the current - * resting children. + * Observe durable ownership for an exact Chase handle through one provider. + * Starts no signing, transport, continuation, cancellation or durable writes. + * Original owner/child IDs are observations, never permission for cleanup in + * a different context. Providers without durable history report unsupported. + * + * @param input - Opaque handle, explicit route and optional original owner. + * @returns Complete local child history, unsupported, or explicit absence. + * @throws On corrupt storage or account/network/provider/lifetime changes. + */ + async getChaseOrderOwnership( + input: GetChaseOrderOwnershipParams, + ): Promise { + const params = { + ...input, + owner: input.owner ? { ...input.owner } : undefined, + }; + if (!params.providerId || !params.handle) { + throw new Error( + 'Chase ownership requires an exact handle and explicit provider', + ); + } + const issuedContext = this.#getActionContext(); + const issuedGeneration = this.#lifecycleGeneration; + const issuedAccountGeneration = this.#selectedAccountGeneration; + const issuedInstance = this.activeProviderInstance; + const assertCurrent = (): void => { + if ( + issuedContext !== this.#getActionContext() || + issuedGeneration !== this.#lifecycleGeneration || + issuedAccountGeneration !== this.#selectedAccountGeneration || + (issuedInstance !== null && + this.activeProviderInstance !== issuedInstance) + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + }; + let provider: PerpsProvider; + try { + provider = await this.#getActiveProviderWhenReady(); + } catch (error) { + assertCurrent(); + throw error; + } + assertCurrent(); + if ( + this.state.activeProvider !== 'aggregated' && + params.providerId !== this.state.activeProvider + ) { + throw new Error( + 'Chase ownership provider does not match the active context', + ); + } + if (!provider.getChaseOrderOwnership) { + return { + status: 'unsupported', + providerId: params.providerId, + handle: params.handle, + reason: 'Durable Chase ownership is unavailable for this provider', + }; + } + const ownership = await provider.getChaseOrderOwnership(params); + assertCurrent(); + if (this.getActiveProviderOrNull() !== provider) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return ownership; + } + + /** + * Reconcile one retained exact Chase cancellation without any new cancellation. + * Unsupported providers are never routed to ordinary financial cancellation. + * + * @param input - Original handle, owner, child and transaction identity. + * @returns Proven terminal state, unresolved cleanup or unsupported capability. + * @throws On invalid ownership or stale provider/account/network lifetime. + */ + async reconcileChaseOrderCancellation( + input: ReconcileChaseOrderCancellationParams, + ): Promise { + const params = { + ...input, + owner: { ...input.owner }, + cancellation: { ...input.cancellation }, + }; + if ( + !params.providerId || + !params.handle || + !params.clientOrderId || + params.owner.providerId !== params.providerId + ) { + throw new Error( + 'Chase cancellation reconciliation requires an exact handle, child and matching owner/provider', + ); + } + const issuedContext = this.#getActionContext(); + const issuedGeneration = this.#lifecycleGeneration; + const issuedAccountGeneration = this.#selectedAccountGeneration; + const issuedInstance = this.activeProviderInstance; + const assertCurrent = (): void => { + if ( + issuedContext !== this.#getActionContext() || + issuedGeneration !== this.#lifecycleGeneration || + issuedAccountGeneration !== this.#selectedAccountGeneration || + (issuedInstance !== null && + this.activeProviderInstance !== issuedInstance) + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + }; + let provider: PerpsProvider; + try { + provider = await this.#getActiveProviderWhenReady(); + } catch (error) { + assertCurrent(); + throw error; + } + assertCurrent(); + if ( + this.state.activeProvider !== 'aggregated' && + params.providerId !== this.state.activeProvider + ) { + throw new Error( + 'Chase cancellation reconciliation provider does not match the active context', + ); + } + if (!provider.reconcileChaseOrderCancellation) { + return { + status: 'unsupported', + providerId: params.providerId, + handle: params.handle, + reason: + 'Chase cancellation reconciliation is unavailable for this provider', + }; + } + const ownership = await provider.reconcileChaseOrderCancellation(params); + assertCurrent(); + if (this.getActiveProviderOrNull() !== provider) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return ownership; + } + + /** + * Stop Chase repricing for app backgrounding. HyperLiquid leaves current + * children resting. The bounded Lighter probe attempts exact cancellation and + * reports canceled or termination_pending rather than a resting status. * * @returns Chase snapshots after suspension. * @throws If an aggregated provider cannot suspend every active venue. Other @@ -3941,7 +4123,10 @@ 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. + * settlement state return an empty list. Lighter includes previous-key and + * pre-initialization journals for the selected wallet/account, waits for any + * in-flight key selection and keeps failed-setup obligations visible. A + * current-key update does not clear an earlier key's unfinished journal. * * @returns Pending manual-recovery entries. */ @@ -3953,12 +4138,58 @@ export class PerpsController extends BaseController< return provider.getPendingManualRecoveries(); } + /** + * Review native attached lifecycle through provider-owned read authority. + * + * @returns Exact venue identities without financial replay or key registration. + */ + async reviewAttachedOrderGroups(): Promise { + const issuedContext = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + const groups = (await provider.reviewAttachedOrderGroups?.()) ?? []; + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return groups; + } + + /** + * List durable attached identities for the selected provider and account. + * + * @returns Stored intent without venue writes, signer setup or automatic replay. + */ + async getAttachedOrderGroups(): Promise { + const issuedContext = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + const groups = (await provider.getAttachedOrderGroups?.()) ?? []; + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return groups; + } + /** * 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. + * durable dispatch state return an empty list. Lighter scans the bounded + * local trading-slot range, including skipped keys, without signing or + * changing quarantine. After controller initialization, this works before + * Lighter signer initialization and after signer setup failure; an in-flight + * selection settles first. Preserve opaque recovery IDs exactly. Raw pending + * dispatches report unknown with acknowledgeable:false and cannot be cleared + * by acknowledgment, including current-session in-flight submissions. Listing + * starts no background reconciliation. A later financial action re-checks + * authoritative state for all account slots before dispatch and remains + * blocked while an obligation is unresolved. + * + * @returns Pending recovered-dispatch outcomes, with their original key slot + * when supplied by the provider. */ async getRecoveredDispatches(): Promise { const provider = await this.#getActiveProviderWhenReady(); @@ -3968,22 +4199,259 @@ export class PerpsController extends BaseController< return provider.getRecoveredDispatches(); } + /** + * Execute an explicit successor for one selected durable protection obligation. + * + * @param params - Owning provider, opaque source ID and new protection intent. + * @returns Settled, unresolved or unsupported recovery result. + */ + async resolveRecoveryProtection( + params: ResolveRecoveryProtectionParams, + ): Promise { + const issuedContext = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + if (this.state.activeProvider === 'aggregated' && !params.providerId) { + throw new Error('Protection recovery requires an explicit provider'); + } + if ( + params.providerId && + this.state.activeProvider !== 'aggregated' && + params.providerId !== this.state.activeProvider + ) { + throw new Error( + 'Protection recovery provider does not match the active context', + ); + } + const providerId = params.providerId ?? this.state.activeProvider; + if (providerId === 'aggregated') { + throw new Error('Protection recovery requires an explicit provider'); + } + if (!provider.resolveRecoveryProtection) { + return { + status: 'unsupported', + providerId, + success: false, + error: 'Selected protection recovery is unavailable for this provider', + }; + } + const traceId = uuidv4(); + this.#options.infrastructure.tracer.trace({ + name: PerpsTraceNames.UpdateTpsl, + op: PerpsTraceOperations.PositionManagement, + id: traceId, + tags: { provider: providerId }, + }); + try { + const result = await provider.resolveRecoveryProtection(params); + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + if (result.status === 'settled' && result.success) { + this.update((state) => { + state.lastUpdateTimestamp = Date.now(); + }); + } + this.#options.infrastructure.tracer.addBreadcrumb({ + category: 'perps.recovery', + message: 'Explicit protection recovery completed', + level: result.status === 'settled' ? 'info' : 'warning', + data: { providerId, status: result.status }, + }); + return result; + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.resolveRecoveryProtection'), + this.#getErrorContext('resolveRecoveryProtection'), + ); + throw error; + } finally { + this.#options.infrastructure.tracer.endTrace({ + name: PerpsTraceNames.UpdateTpsl, + id: traceId, + }); + } + } + + /** + * Read durable Scale groups without placing or replaying any child. + * @returns Groups belonging to the issuing controller context. + */ + async getScaleOrderGroups(): Promise { + const context = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (context !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + const groups = (await provider.getScaleOrderGroups?.()) ?? []; + if (context !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return groups; + } + + /** + * Reconcile durable Scale groups for an explicitly selected provider. + * @param params - Issuing provider route. + * @param params.providerId - Provider to review. + * @returns Fresh durable groups; never replays placement. + */ + async reviewScaleOrderGroups( + params: { providerId?: PerpsProviderType } = {}, + ): Promise { + const context = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (context !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + if (this.state.activeProvider === 'aggregated' && !params.providerId) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + } + if ( + params.providerId && + this.state.activeProvider !== 'aggregated' && + params.providerId !== this.state.activeProvider + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + } + if (!provider.reviewScaleOrderGroups) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + const groups = await provider.reviewScaleOrderGroups(params); + if (context !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return groups; + } + + /** + * Review fresh venue positions and orders for one issuing provider context. + * Auth signing may be required; registration and financial writes are forbidden. + * + * @param params - Owning provider route. + * @param params.providerId - Explicit provider identifier. + * @returns Strict venue review or honest unsupported capability. + */ + async reviewRecoveryVenue( + params: { providerId?: PerpsProviderType } = {}, + ): Promise { + const issuedContext = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + if (this.state.activeProvider === 'aggregated' && !params.providerId) { + throw new Error('Recovery review requires an explicit provider'); + } + if ( + params.providerId && + this.state.activeProvider !== 'aggregated' && + params.providerId !== this.state.activeProvider + ) { + throw new Error( + 'Recovery review provider does not match the active context', + ); + } + if (!provider.reviewRecoveryVenue) { + const providerId = params.providerId ?? this.state.activeProvider; + if (providerId === 'aggregated') { + throw new Error('Recovery review requires an explicit provider'); + } + return { + status: 'unsupported', + providerId, + reason: + 'Authoritative recovery review is unavailable for this provider', + }; + } + const review = await provider.reviewRecoveryVenue(params); + if (issuedContext !== this.#getActionContext()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return review; + } + + /** + * Explicit non-financial reconciliation with local persistence. Never signs, + * retries or acknowledges dispatches. Unsupported providers return their local + * listed state, or an empty list when neither capability is available. + * Rejects account, network or provider changes while controller readiness, + * reconciliation or fallback listing completes. Provider rejections propagate + * unchanged. + * + * @returns Newly scoped pending and recovered dispatches. + */ + async reconcileRecoveredDispatches(): Promise { + const issuedContext = this.#getActionContext(); + const provider = await this.#getActiveProviderWhenReady(); + if (this.#getActionContext() !== issuedContext) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + const dispatches = provider.reconcileRecoveredDispatches + ? await provider.reconcileRecoveredDispatches() + : ((await provider.getRecoveredDispatches?.()) ?? []); + if (this.#getActionContext() !== issuedContext) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return dispatches; + } + /** * 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}. + * durable dispatch state or the id no longer matches. Lighter scopes IDs to + * wallet/network/account/key and accepts legacy IDs only when unambiguous + * across local account ledgers. Acknowledgment removes one stored outcome, + * never an unresolved dispatch or a TP/SL journal, and authorizes no retry. + * Rejects account, network, provider or lifecycle changes while readiness or + * acknowledgment completes. A stale rejection after provider success does + * not undo removal in the issuing account. Re-list outcomes before acting + * again. Provider rejections propagate unchanged. + * + * @param recoveryId - Opaque stable id from {@link getRecoveredDispatches}. * @returns Resolves when the outcome is acknowledged. */ async acknowledgeRecoveredDispatch(recoveryId: string): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const issuedContext = this.#getActionContext(); + const issuedGeneration = this.#lifecycleGeneration; + const issuedInstance = + this.isInitialized || this.isCurrentlyReinitializing() + ? this.activeProviderInstance + : null; + const isIssuingLifetimeStale = (): boolean => + issuedGeneration !== this.#lifecycleGeneration || + issuedContext !== this.#getActionContext() || + (issuedInstance !== null && + this.getActiveProviderOrNull() !== issuedInstance); + let provider: PerpsProvider; + try { + provider = await this.#getActiveProviderWhenReady(); + } catch (error) { + if (isIssuingLifetimeStale()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + throw error; + } + if ( + isIssuingLifetimeStale() || + this.getActiveProviderOrNull() !== provider + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } if (!provider.acknowledgeRecoveredDispatch) { throw new Error( 'The active perps provider has no recovered dispatches to acknowledge', ); } - return provider.acknowledgeRecoveredDispatch(recoveryId); + await provider.acknowledgeRecoveredDispatch(recoveryId); + if ( + isIssuingLifetimeStale() || + this.getActiveProviderOrNull() !== provider + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } } /** @@ -4281,6 +4749,9 @@ export class PerpsController extends BaseController< #lifecycleGeneration = 0; + // Account events also fence round trips that finish at the original address. + #selectedAccountGeneration = 0; + readonly #userSnapshotRequests = new Map< string, { provider: PerpsProvider; promise: Promise } @@ -5180,12 +5651,26 @@ export class PerpsController extends BaseController< async validateOrder( params: OrderParams, ): Promise<{ isValid: boolean; error?: string }> { + let capturedParams: OrderParams; + try { + capturedParams = captureScaleOrderParams(params); + } catch (error) { + return { + isValid: false, + error: ensureError(error, 'PerpsController.captureScaleOrderParams') + .message, + }; + } const provider = await this.#resolveRoutedOrderProvider({ - orderType: params.orderType, - providerId: params.providerId, + orderType: capturedParams.orderType, + providerId: capturedParams.providerId, }); const context = this.#createServiceContext('validateOrder'); - return this.#marketDataService.validateOrder({ provider, params, context }); + return this.#marketDataService.validateOrder({ + provider, + params: capturedParams, + context, + }); } /** diff --git a/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts b/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts index 12037933d97..f4dfa779ec9 100644 --- a/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts +++ b/packages/perps-controller/src/aggregation/SubscriptionMultiplexer.ts @@ -15,7 +15,6 @@ import type { PerpsLogger, PriceUpdate, Position, - OrderFill, Order, AccountState, SubscribePricesParams, @@ -73,20 +72,24 @@ export type MultiplexedPositionsParams = { * Parameters for multiplexed order fill subscriptions */ export type MultiplexedOrderFillsParams = { + /** Report provider errors without emitting a fabricated empty snapshot. */ + onError?: SubscribeOrderFillsParams['onError']; /** Provider instances to subscribe through */ providers: [PerpsProviderType, PerpsProvider][]; /** Callback to receive aggregated order fill updates */ - callback: (fills: OrderFill[], isSnapshot?: boolean) => void; + callback: SubscribeOrderFillsParams['callback']; }; /** * Parameters for multiplexed order subscriptions */ export type MultiplexedOrdersParams = { + /** Report provider errors without emitting a fabricated empty snapshot. */ + onError?: SubscribeOrdersParams['onError']; /** Provider instances to subscribe through */ providers: [PerpsProviderType, PerpsProvider][]; /** Callback to receive aggregated order updates */ - callback: (orders: Order[]) => void; + callback: SubscribeOrdersParams['callback']; }; /** @@ -324,6 +327,7 @@ export class SubscriptionMultiplexer { for (const [providerId, provider] of providers) { try { const subscribeParams: SubscribeOrderFillsParams = { + onError: (error) => params.onError?.(error, providerId), callback: (fills, isSnapshot) => { // Tag fills with providerId const taggedFills = fills.map((fill) => ({ @@ -332,7 +336,7 @@ export class SubscriptionMultiplexer { })); // For fills, we don't aggregate - emit immediately with tags - callback(taggedFills, isSnapshot); + callback(taggedFills, isSnapshot, providerId); }, }; @@ -376,6 +380,7 @@ export class SubscriptionMultiplexer { for (const [providerId, provider] of providers) { try { const subscribeParams: SubscribeOrdersParams = { + onError: (error) => params.onError?.(error, providerId), callback: (orders) => { // Tag orders with providerId and cache const taggedOrders = orders.map((order) => ({ @@ -386,7 +391,7 @@ export class SubscriptionMultiplexer { // Emit aggregated orders from all providers const allOrders = this.#aggregateOrders(); - callback(allOrders); + callback(allOrders, providerId); }, }; diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 426b1843e98..6e7102c95a6 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -15,7 +15,10 @@ import type { TradingDefaultsConfig, FeeRatesConfig, } from '../types/perps-types.js'; -import { STRATEGY_ORDER_TYPES } from '../utils/orderTypes.js'; +import { + STRATEGY_ORDER_TYPES, + TRIGGER_ORDER_TYPES, +} from '../utils/orderTypes.js'; import { PROVIDER_CONFIG } from './perpsConfig.js'; // Network constants @@ -211,7 +214,7 @@ export const BUILDER_FEE_CONFIG = { }; /** - * Strategies that HyperLiquid can execute for its routed perp markets. + * Strategies and standalone trigger orders HyperLiquid executes for its markets. * Providers own this declaration so clients never infer support from a * provider name. */ @@ -219,6 +222,20 @@ export const HYPERLIQUID_ORDER_CAPABILITIES = Object.freeze({ status: 'ready', providerId: PROVIDER_CONFIG.DefaultProvider, supportedStrategies: Object.freeze([...STRATEGY_ORDER_TYPES]), + supportedTriggerOrderTypes: Object.freeze([...TRIGGER_ORDER_TYPES]), + positionTpsl: Object.freeze({ + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: Object.freeze({ + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }), + }), }) satisfies DirectProviderOrderCapabilities; // Referral code configuration diff --git a/packages/perps-controller/src/constants/lighterConfig.ts b/packages/perps-controller/src/constants/lighterConfig.ts index 8a4df4818e5..3edf43be98e 100644 --- a/packages/perps-controller/src/constants/lighterConfig.ts +++ b/packages/perps-controller/src/constants/lighterConfig.ts @@ -160,10 +160,20 @@ export function getLighterTransactionOutcome( // Order enums (wire values expected by `_signCreateOrder`) // ============================================================================ +/** The pinned signer represents execution and trigger prices as uint32. */ +export const LIGHTER_MAX_WIRE_PRICE = 4_294_967_295; + 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_STOP_LOSS_LIMIT = 3; export const LIGHTER_ORDER_TYPE_TAKE_PROFIT = 4; +export const LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT = 5; + +/** Native parent plus one attached child (OTO). */ +export const LIGHTER_GROUPING_ONE_TRIGGERS_THE_OTHER = 1; +/** Native parent plus mutually cancelling attached children (OTOCO). */ +export const LIGHTER_GROUPING_ONE_TRIGGERS_OCO = 3; /** Grouped-orders grouping type: one-cancels-the-other (OCO). */ export const LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER = 2; @@ -181,7 +191,7 @@ 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). */ +/** Default pending expiry for GTT and trigger orders (per lighter SDKs). */ export const LIGHTER_ORDER_EXPIRY_NONE = -1; /** Sentinel for "no trigger price". */ export const LIGHTER_NO_TRIGGER_PRICE = 0; @@ -196,6 +206,20 @@ export const LIGHTER_NO_TRIGGER_PRICE = 0; * slot avoids clobbering keys registered by other clients. */ export const LIGHTER_DEFAULT_API_KEY_INDEX = 7; +/** Inclusive venue range available for trading API keys. */ +export const LIGHTER_MIN_TRADING_API_KEY_INDEX = 2; +export const LIGHTER_MAX_TRADING_API_KEY_INDEX = 254; +/** Bounded number of trading slots scanned for account-local recovery state. */ +export const LIGHTER_TRADING_API_KEY_COUNT = + LIGHTER_MAX_TRADING_API_KEY_INDEX - LIGHTER_MIN_TRADING_API_KEY_INDEX + 1; +/** Bound registration visibility reads before returning a retryable failure. */ +export const LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS = 10_000; +export const LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS = 250; +export const LIGHTER_KEY_REGISTRATION_VISIBILITY_MAX_ATTEMPTS = + LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS / + LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS; +/** Maximum newest-first complete fill history retained for late subscribers. */ +export const LIGHTER_FILL_REPLAY_LIMIT = 100; // ============================================================================ // REST API Configuration @@ -383,3 +407,54 @@ export const LIGHTER_MARGIN_MODE_ISOLATED = 1; */ export const LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX = 'Unsupported Lighter capability:'; + +/** Maximum verified venue accounts remembered per wallet/network for recovery inventory. */ +export const LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT = 64; + +/** Clock slack for signed transaction expiry and venue trade provenance. */ +export const LIGHTER_TX_EXPIRY_SLACK_MS = 30_000; + +/** Fixed native wire bounds and bounded probe inventory limits. */ +export const LIGHTER_MAX_MARKET_ID = 65535; +export const LIGHTER_MAX_BASE_AMOUNT = '281474976710655'; +/** Inclusive uint48 client-order identity bound. */ +export const LIGHTER_MAX_CLIENT_ORDER_INDEX = '281474976710655'; +export const LIGHTER_MAX_ORDER_PRICE = '4294967295'; +export const LIGHTER_MAX_ORDER_ID = '1152921504606846975'; +export const LIGHTER_MAX_DECIMALS = 255; +export const LIGHTER_MINUTE_MS = 60_000; +export const LIGHTER_NATIVE_PROBE_RECORD_LIMIT = 64; +export const LIGHTER_NATIVE_PROBE_CANCEL_LIMIT = 16; +export const LIGHTER_NATIVE_PROBE_PAGE_LIMIT = 100; +export const LIGHTER_NATIVE_PROBE_PAGE_SIZE = 100; +export const LIGHTER_CLIENT_ORDER_LOOKUP_LIMIT = 20; + +/** Shared bounded testnet native-probe notional ceiling, in USD. */ +export const LIGHTER_NATIVE_PROBE_MAX_NOTIONAL = 20; + +/** Settlement budget shared by every cancellation in one Scale group. */ +export const LIGHTER_SCALE_SETTLEMENT_WINDOW_MS = 10_000; + +/** Read-only Scale visibility cadence, also used after each placement. */ +export const LIGHTER_SCALE_SETTLEMENT_POLL_MS = 250; + +/** Read-only settlement budget after an exact Chase cancellation dispatch. */ +export const LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS = 10_000; + +/** Delay between exact Chase child reads, without another sign or send. */ +export const LIGHTER_CHASE_CANCEL_SETTLEMENT_POLL_MS = 250; + +/** Bounded Chase policy, distinct from native wire limits. */ +export const LIGHTER_CHASE_DEFAULT_INTERVAL_MS = 15_000; +export const LIGHTER_CHASE_DEFAULT_DURATION_MS = 60_000; +export const LIGHTER_CHASE_DEFAULT_REPRICINGS = 1; +export const LIGHTER_CHASE_DEFAULT_DISTANCE_BPS = 100; +export const LIGHTER_CHASE_MIN_INTERVAL_MS = 1_000; +export const LIGHTER_CHASE_MAX_DURATION_MS = 300_000; +export const LIGHTER_CHASE_MAX_REPRICINGS = 20; +export const LIGHTER_CHASE_MAX_DISTANCE_BPS = 10_000; +export const LIGHTER_CHASE_QUOTE_MAX_AGE_MS = 5_000; +export const LIGHTER_CHASE_HANDLE_MAX_LENGTH = 128; + +/** Maximum duration of a post-only crossing-check book read. */ +export const LIGHTER_POST_ONLY_QUOTE_MAX_AGE_MS = 5_000; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index ee272db3633..73e07901e53 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -92,6 +92,8 @@ export type { PerpsControllerGetCurrentNetworkAction, PerpsControllerGetFundingAction, PerpsControllerGetChaseOrdersAction, + PerpsControllerGetChaseOrderOwnershipAction, + PerpsControllerReconcileChaseOrderCancellationAction, PerpsControllerGetTwapOrdersAction, PerpsControllerGetHistoricalPortfolioAction, PerpsControllerGetMarketDataWithPricesAction, @@ -113,6 +115,13 @@ export type { PerpsControllerGetPositionsAction, PerpsControllerGetSelectedOrderTypeAction, PerpsControllerGetRecoveredDispatchesAction, + PerpsControllerGetAttachedOrderGroupsAction, + PerpsControllerReviewAttachedOrderGroupsAction, + PerpsControllerGetScaleOrderGroupsAction, + PerpsControllerReviewScaleOrderGroupsAction, + PerpsControllerReconcileRecoveredDispatchesAction, + PerpsControllerReviewRecoveryVenueAction, + PerpsControllerResolveRecoveryProtectionAction, PerpsControllerAcknowledgeRecoveredDispatchAction, PerpsControllerGetTradeConfigurationAction, PerpsControllerGetRecentlyViewedMarketsAction, @@ -209,10 +218,22 @@ export type { TrackingData, TPSLTrackingData, OrderParams, + ExpectedScaleLadder, MarginMode, OrderResult, + PositionProtectionReceipt, + LighterCredentials, + OrderEditObservation, + AttachedOrderGroup, ScaleOrderChild, ChaseOrder, + GetChaseOrderOwnershipParams, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, + PerpsChaseOrderOwner, + PerpsChaseOrderDispatch, + PerpsChaseOrderChild, + PerpsChaseOrderOwnership, ChaseOrderMaxDistanceReached, ChaseOrderStatus, TwapOrder, @@ -220,6 +241,10 @@ export type { TwapOrderStatus, PerpsPendingManualRecovery, PerpsRecoveredDispatch, + PerpsRecoveryVenueReview, + ScaleOrderGroup, + ResolveRecoveryProtectionParams, + PerpsRecoveryProtectionResult, Position, AccountState, ClosePositionParams, @@ -515,6 +540,7 @@ export type { LighterWebSocketCtor, LighterWebSocketLike, LighterWasmCall, + LighterSignModifyOrderWireParams, LighterAuthConfig, } from './types/lighter-types.js'; export { diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index 0f599f78e63..b93d8b91e96 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -76,6 +76,7 @@ export const PERPS_ERROR_CODES = { ORDER_STRATEGY_CANCEL_INCOMPLETE: 'ORDER_STRATEGY_CANCEL_INCOMPLETE', // Part of a strategy placement is still resting after a cancel; the handle stays valid for a retry ORDER_TWAP_DURATION_REQUIRED: 'ORDER_TWAP_DURATION_REQUIRED', // TWAP placed without twapDuration ORDER_TWAP_DURATION_INVALID: 'ORDER_TWAP_DURATION_INVALID', // twapDuration not a whole number of minutes within the venue's bounds + ORDER_SCALE_PREVIEW_STALE: 'ORDER_SCALE_PREVIEW_STALE', // Approved ladder or constraints changed; refresh the preview ORDER_SCALE_RANGE_REQUIRED: 'ORDER_SCALE_RANGE_REQUIRED', // Scale placed without both ladder bounds ORDER_SCALE_RANGE_INVALID: 'ORDER_SCALE_RANGE_INVALID', // Scale ladder bounds or skew are invalid ORDER_SCALE_COUNT_INVALID: 'ORDER_SCALE_COUNT_INVALID', // scaleNumOrders missing, non-integer, or outside the supported ladder size diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 64e33c0eee7..bfd58f8a682 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -32,6 +32,10 @@ import type { CancelOrderResult, CancelOrdersResult, ChaseOrder, + GetChaseOrderOwnershipParams, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, + PerpsChaseOrderOwnership, ClosePositionParams, ClosePositionsParams, ClosePositionsResult, @@ -75,6 +79,11 @@ import type { PerpsScalePriceLadder, PerpsPendingManualRecovery, PerpsRecoveredDispatch, + AttachedOrderGroup, + PerpsRecoveryVenueReview, + ScaleOrderGroup, + ResolveRecoveryProtectionParams, + PerpsRecoveryProtectionResult, PerpsProviderType, Position, ReadyToTradeResult, @@ -401,9 +410,9 @@ export class AggregatedPerpsProvider implements PerpsProvider { /** * Normalize a Scale price ladder through the selected provider route. * - * @param params - Market, ladder bounds, count, and optional explicit route. - * @returns Provider-normalized prices or a typed unavailable result. - * @throws When the selected provider cannot normalize the requested ladder. + * @param params - Market, ladder bounds, count, optional sizing and explicit route. + * @returns Provider-normalized prices with sizingPreview when supported, or a typed unavailable result. + * @throws When bounds or sizing violate the provider's venue rules. */ async getScalePriceLadder( params: GetScalePriceLadderParams, @@ -680,6 +689,80 @@ export class AggregatedPerpsProvider implements PerpsProvider { return this.#extractSuccessfulResults(results, 'getChaseOrders').flat(); } + /** + * Route a strict local ownership read once, without aggregating partial inventories. + * + * @param input - Exact stable handle and explicit provider route. + * @returns That provider's durable history or an explicit unsupported result. + */ + async getChaseOrderOwnership( + input: GetChaseOrderOwnershipParams, + ): Promise { + const params = { + ...input, + owner: input.owner ? { ...input.owner } : undefined, + }; + if (!params.providerId || !params.handle) { + throw new Error( + 'Chase ownership requires an exact handle and explicit provider', + ); + } + const [, provider] = this.#getProviderOrDefault(params.providerId); + const ownership: PerpsChaseOrderOwnership = provider.getChaseOrderOwnership + ? await provider.getChaseOrderOwnership(params) + : { + status: 'unsupported', + providerId: params.providerId, + handle: params.handle, + reason: 'Durable Chase ownership is unavailable for this provider', + }; + if (this.#providers.get(params.providerId) !== provider) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return ownership; + } + + /** + * Route observation-only reconciliation to exactly one named provider. + * + * @param input - Original handle, owner, child and cancellation identity. + * @returns Exact owning-provider settlement or unsupported, never ordinary cancel. + */ + async reconcileChaseOrderCancellation( + input: ReconcileChaseOrderCancellationParams, + ): Promise { + const params = { + ...input, + owner: { ...input.owner }, + cancellation: { ...input.cancellation }, + }; + if ( + !params.providerId || + !params.handle || + !params.clientOrderId || + params.owner.providerId !== params.providerId + ) { + throw new Error( + 'Chase cancellation reconciliation requires an exact handle, child and matching owner/provider', + ); + } + const [, provider] = this.#getProviderOrDefault(params.providerId); + const ownership: ReconcileChaseOrderCancellationResult = + provider.reconcileChaseOrderCancellation + ? await provider.reconcileChaseOrderCancellation(params) + : { + status: 'unsupported', + providerId: params.providerId, + handle: params.handle, + reason: + 'Chase cancellation reconciliation is unavailable for this provider', + }; + if (this.#providers.get(params.providerId) !== provider) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + return ownership; + } + /** * Attempt to suspend every provider and reject after all attempts settle if * any provider could not suspend safely. Successful providers remain @@ -818,15 +901,52 @@ export class AggregatedPerpsProvider implements PerpsProvider { */ async getPendingManualRecoveries(): Promise { const results = await Promise.all( - this.#getActiveProviders().map(async ([, provider]) => + this.#getActiveProviders().map(async ([providerId, provider]) => provider.getPendingManualRecoveries - ? provider.getPendingManualRecoveries() + ? (await provider.getPendingManualRecoveries()).map((row) => ({ + ...row, + providerId, + })) : [], ), ); return results.flat(); } + /** + * Review native attached groups on providers implementing that capability. + * + * @returns Provider-labelled venue observations without financial actions. + */ + async reviewAttachedOrderGroups(): Promise { + const results = await Promise.all( + this.#getActiveProviders().map(async ([providerId, provider]) => + ((await provider.reviewAttachedOrderGroups?.()) ?? []).map((group) => ({ + ...group, + providerId, + })), + ), + ); + return results.flat(); + } + + /** + * List stored attached identities across active providers. + * + * @returns Provider-labelled groups without financial actions. + */ + async getAttachedOrderGroups(): Promise { + const results = await Promise.all( + this.#getActiveProviders().map(async ([providerId, provider]) => + ((await provider.getAttachedOrderGroups?.()) ?? []).map((group) => ({ + ...group, + providerId, + })), + ), + ); + return results.flat(); + } + /** * Aggregate recovered-dispatch outcomes from every underlying provider * implementing the durable-settlement contract. @@ -835,15 +955,119 @@ export class AggregatedPerpsProvider implements PerpsProvider { */ async getRecoveredDispatches(): Promise { const results = await Promise.all( - this.#getActiveProviders().map(async ([, provider]) => + this.#getActiveProviders().map(async ([providerId, provider]) => provider.getRecoveredDispatches - ? provider.getRecoveredDispatches() + ? (await provider.getRecoveredDispatches()).map((row) => ({ + ...row, + providerId, + })) : [], ), ); return results.flat(); } + /** + * List durable Scale groups from providers that expose group ownership. + * @returns Owned groups with their provider routes. + */ + async getScaleOrderGroups(): Promise { + const groups = await Promise.all( + this.#getActiveProviders().map(async ([providerId, provider]) => + ((await provider.getScaleOrderGroups?.()) ?? []).map((group) => ({ + ...group, + providerId, + })), + ), + ); + return groups.flat(); + } + + /** + * Review one selected provider's Scale groups without replaying placement. + * @param params - Explicit provider route. + * @param params.providerId - Owning provider. + * @returns Reconciled durable groups. + */ + async reviewScaleOrderGroups( + params: { providerId?: PerpsProviderType } = {}, + ): Promise { + if (!params.providerId) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + } + const [, provider] = this.#getProviderOrDefault(params.providerId); + if (!provider.reviewScaleOrderGroups) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + return provider.reviewScaleOrderGroups(params); + } + + /** + * Execute an explicit successor for one selected durable protection obligation. + * + * @param params - Owning provider, opaque source ID and new protection intent. + * @returns Settled, unresolved or unsupported recovery result. + */ + async resolveRecoveryProtection( + params: ResolveRecoveryProtectionParams, + ): Promise { + if (!params.providerId) { + throw new Error('Protection recovery requires an explicit provider'); + } + const [, provider] = this.#getProviderOrDefault(params.providerId); + return ( + provider.resolveRecoveryProtection?.(params) ?? { + status: 'unsupported', + providerId: params.providerId, + success: false, + error: 'Selected protection recovery is unavailable for this provider', + } + ); + } + + /** + * Review only the selected recovery owner, never an aggregated account snapshot. + * + * @param params - Explicit owning-provider route. + * @param params.providerId - Provider to review. + * @returns That provider's strict snapshot or unsupported capability. + */ + async reviewRecoveryVenue( + params: { providerId?: PerpsProviderType } = {}, + ): Promise { + if (!params.providerId) { + throw new Error('Recovery review requires an explicit provider'); + } + const [, provider] = this.#getProviderOrDefault(params.providerId); + return ( + provider.reviewRecoveryVenue?.(params) ?? { + status: 'unsupported', + providerId: params.providerId, + reason: + 'Authoritative recovery review is unavailable for this provider', + } + ); + } + + /** + * Reconcile every active provider without financial writes. Unsupported + * providers contribute their local listing, or an empty list. Any failure + * rejects the entire result, so partial evidence is never reported as complete. + * + * @returns Newly scoped pending and recovered dispatches across providers. + */ + async reconcileRecoveredDispatches(): Promise { + const results = await Promise.all( + this.#getActiveProviders().map(async ([providerId, provider]) => { + const rows = provider.reconcileRecoveredDispatches + ? await provider.reconcileRecoveredDispatches() + : ((await provider.getRecoveredDispatches?.()) ?? []); + return rows.map((row) => ({ ...row, providerId })); + }), + ); + return results.flat(); + } + /** * Acknowledge ONE recovered-dispatch outcome by its stable id on * whichever underlying provider owns it. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index bc43548ae91..d55362eadcc 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -42,6 +42,7 @@ import { PERFORMANCE_CONFIG, PERPS_CONSTANTS, PROVIDER_CONFIG, + SUBSCRIPTION_CLOID_CONFIG, TP_SL_CONFIG, WITHDRAWAL_CONSTANTS, } from '../constants/perpsConfig.js'; @@ -248,6 +249,7 @@ import { resolvePositionTriggerSummaryPrice, toSDKTimeInForce, } from '../utils/orderTypes.js'; +import { assertExpectedPosition } from '../utils/positionProtection.js'; import { createStandaloneInfoClient, queryStandaloneClearinghouseStates, @@ -317,6 +319,26 @@ const HISTORICAL_ORDER_STATUS_BY_SDK_STATUS = { const isStatusObject = (status: unknown): status is Record => typeof status === 'object' && status !== null; +/** + * Allocate an unmarked protection identity without reusing this operation's IDs. + * + * @param allocated - IDs already allocated for replacement or restoration. + * @returns A unique client ID with the subscription layout and no fee flag. + */ +const createUnmarkedProtectionCloid = (allocated: Set): Hex => { + const unmarkedFlags = 0; + const flags = unmarkedFlags.toString(16).padStart(2, '0'); + const entropy = uuidv4() + .replace(/-/gu, '') + .slice(0, SUBSCRIPTION_CLOID_CONFIG.EntropyHexLength); + const cloid: Hex = `0x${SUBSCRIPTION_CLOID_CONFIG.ProgramId}${flags}${entropy}`; + if (allocated.has(cloid)) { + throw new Error('Duplicate TP/SL client order identity'); + } + allocated.add(cloid); + return cloid; +}; + type ScaleBulkOrderResponse = { status: 'ok'; response: { @@ -1931,13 +1953,13 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Return provider-owned strategy and margin-mode support for a routed - * market. HyperLiquid metadata has no per-market strategy flags. This - * provider advertises its implemented strategies uniformly after confirming - * the routed market exists; margin modes follow the market's metadata. + * Return provider-owned standalone trigger, strategy and margin-mode support + * for a routed market. HyperLiquid advertises its implemented trigger types + * and strategies uniformly after confirming the routed market exists; + * margin modes follow the market's metadata. * * @param params - Required market route context. - * @returns Supported strategy order types and margin modes. + * @returns Supported standalone trigger types, strategies and margin modes. */ async getOrderCapabilities( params: GetOrderCapabilitiesParams, @@ -2832,12 +2854,15 @@ export class HyperLiquidProvider implements PerpsProvider { * block remains the safety net. * * @param userAddress - The wallet address to check. + * @param infoClient - Optional captured reader for an operation-scoped probe. * @returns True only when Hyperliquid reports a multi-sig signer set. * @private */ - async #isHyperliquidMultiSigAccount(userAddress: string): Promise { + async #isHyperliquidMultiSigAccount( + userAddress: string, + infoClient = this.#clientService.getInfoClient(), + ): Promise { try { - const infoClient = this.#clientService.getInfoClient(); const signers = await infoClient.userToMultiSigSigners({ user: userAddress, }); @@ -6383,6 +6408,12 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns A promise that resolves to the result. */ async placeOrder(params: OrderParams, retryCount = 0): Promise { + if (params.expectedScaleLadder !== undefined) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE, + }; + } // Hoisted so the retry path in the catch block can use the fetched price // even when the caller (e.g. flipPosition) omits currentPrice from params. let effectivePrice: number | undefined; @@ -9268,14 +9299,17 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param exchangeClient - Client that owns the orders. * @param requests - Venue cancel requests. + * @param onError - Optional operation-specific failure classifier. * @returns Order IDs that may still be resting. */ async #cancelOrderRequests( exchangeClient: ExchangeClient, requests: ExchangeCancelRequest[], + onError?: (error: unknown) => void | Promise, ): Promise { - return (await this.#cancelOrderRequestBatch(exchangeClient, requests)) - .remainingOrderIds; + return ( + await this.#cancelOrderRequestBatch(exchangeClient, requests, onError) + ).remainingOrderIds; } /** @@ -9376,11 +9410,13 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param exchangeClient - Client that owns the orders. * @param requests - Venue cancel requests. + * @param onError - Optional operation-specific failure classifier. * @returns IDs that may still rest and IDs this call confirmed it cancelled. */ async #cancelOrderRequestBatch( exchangeClient: ExchangeClient, requests: ExchangeCancelRequest[], + onError?: (error: unknown) => void | Promise, ): Promise { if (requests.length === 0) { return { @@ -9431,6 +9467,7 @@ export class HyperLiquidProvider implements PerpsProvider { return await classifyStatuses(statuses, result); } catch (error) { + await onError?.(error); // The SDK throws when any entry failed, with every entry's status. const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { @@ -9514,103 +9551,137 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Recover IDs omitted from waiting trigger acknowledgements. - * - * HyperLiquid does not preserve submission order in `frontendOpenOrders`, so - * each unresolved leg is matched by its submitted trigger attributes and - * accepted only when exactly one new order qualifies. - * - * @param params - Reconciliation parameters. - * @param params.outcomes - Classified placement responses. - * @param params.orders - Submitted TP/SL orders. - * @param params.previousOrderIds - Order IDs observed before submission. - * @param params.dexName - DEX queried for the placement. - * @param params.symbol - Market the triggers protect. - * @returns Outcomes enriched with unambiguous exchange order IDs. + * Preserve the raw statuses carried by the SDK's bulk rejection. + * + * @param error - SDK exchange error. + * @returns Exact reported statuses, or undefined for no authoritative batch. + */ + #readProtectionErrorStatuses(error: unknown): unknown[] | undefined { + const response = + error instanceof Error && hasProperty(error, 'response') + ? error.response + : undefined; + return isStatusObject(response) && + isStatusObject(response.response) && + response.response.type === 'order' && + isStatusObject(response.response.data) && + Array.isArray(response.response.data.statuses) + ? response.response.data.statuses + : undefined; + } + + /** + * Distinguish a venue rejection from ambiguous transport failure. + * + * @param error - SDK exchange error. + * @returns Whether the venue authoritatively rejected the entire action. + */ + #isDefinitiveProtectionRejection(error: unknown): boolean { + const response = + error instanceof Error && hasProperty(error, 'response') + ? error.response + : undefined; + return ( + isStatusObject(response) && + response.status === 'err' && + typeof response.response === 'string' + ); + } + + /** + * Apply both signer invalidation and account-shape classification to writes. + * + * @param error - Failed protection write. + * @returns Actionable error retaining the venue message when unmapped. + */ + async #classifyProtectionWriteError(error: unknown): Promise { + return ( + (await this.#classifySignerFailure(error)) ?? + (isHyperLiquidMultiSigRequiredError(error) + ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED) + : ensureError(error, 'HyperLiquidProvider.updatePositionTPSL')) + ); + } + + /** + * Correlate protection outcomes only by final signed cloid and market. + * Unknown/truncated acknowledgements never acquire ownership from book shape. + * + * @param params - Captured request identities and scoped status reader. + * @param params.outcomes - Classified SDK statuses, possibly truncated. + * @param params.orders - Exact signed orders, including final cloids. + * @param params.userAddress - Captured submitting account. + * @param params.infoClient - Captured venue reader. + * @param params.assertScope - Account/network/lifecycle fence. + * @param params.symbol - Requested venue market. + * @returns Ordered outcomes with exact identities where resolved. */ async #reconcileTpslOrderPlacementOutcomes(params: { outcomes: TpslOrderPlacementOutcome[]; orders: SDKOrderParams[]; - previousOrderIds: ReadonlySet; - dexName: string | null; + userAddress: Hex; + infoClient: InfoClient; + assertScope: () => Promise; symbol: string; }): Promise { - if ( - !params.outcomes.some( - (outcome) => - outcome.state === 'waitingForTrigger' && - outcome.orderId === undefined, - ) - ) { - return params.outcomes; - } - - try { - const appearedOrders = ( - await this.#fetchOpenOrders({ dexName: params.dexName }) - ).filter( - (order) => - order.coin === params.symbol && - order.reduceOnly && - order.isTrigger && - !params.previousOrderIds.has(order.oid.toString()), - ); - const claimedOrderIds = new Set(); - - return params.outcomes.map((outcome, index) => { - if ( - outcome.orderId !== undefined || - outcome.state !== 'waitingForTrigger' - ) { - return outcome; - } - - const submittedOrder = params.orders[index]; - const trigger = hasProperty(submittedOrder.t, 'trigger') - ? submittedOrder.t.trigger - : undefined; - if ( - !isStatusObject(trigger) || - !hasProperty(trigger, 'triggerPx') || - (typeof trigger.triggerPx !== 'string' && - typeof trigger.triggerPx !== 'number') || - !hasProperty(trigger, 'tpsl') || - (trigger.tpsl !== 'tp' && trigger.tpsl !== 'sl') - ) { - return outcome; - } - const submittedSize = parseFloat(String(submittedOrder.s)); - const submittedTriggerPrice = parseFloat(String(trigger.triggerPx)); - const expectedOrderType = - trigger.tpsl === 'tp' ? 'Take Profit' : 'Stop'; - const candidates = appearedOrders.filter( - (order) => - !claimedOrderIds.has(order.oid) && - (order.side === 'B') === submittedOrder.b && - parseFloat(order.sz) === submittedSize && - parseFloat(order.triggerPx) === submittedTriggerPrice && - order.orderType.includes(expectedOrderType), - ); - if (candidates.length !== 1) { - return outcome; + const correlated: TpslOrderPlacementOutcome[] = []; + for (const [index, order] of params.orders.entries()) { + const outcome = params.outcomes[index] ?? { state: 'unknown' as const }; + if (outcome.state === 'rejected' || outcome.orderId !== undefined) { + correlated.push(outcome); + continue; + } + let resolved = outcome; + if (order.c !== undefined) { + await params.assertScope(); + try { + const status = await params.infoClient.orderStatus({ + user: params.userAddress, + oid: order.c, + }); + await params.assertScope(); + if (status.status === 'order') { + const row = status.order.order; + if ( + row.coin === params.symbol && + row.cloid?.toLowerCase() === order.c.toLowerCase() && + Number.isSafeInteger(row.oid) && + row.oid > 0 + ) { + let state: TpslOrderPlacementOutcome['state'] = 'unknown'; + if (status.order.status === 'filled') { + state = 'filled'; + } else if (status.order.status === 'open') { + state = 'resting'; + } else if ( + hasProperty( + HISTORICAL_ORDER_STATUS_BY_SDK_STATUS, + status.order.status, + ) + ) { + const terminal = + HISTORICAL_ORDER_STATUS_BY_SDK_STATUS[status.order.status]; + if (terminal === 'canceled' || terminal === 'rejected') { + state = 'rejected'; + } + } + resolved = { orderId: String(row.oid), state }; + } + } + } catch { + await params.assertScope(); } - - claimedOrderIds.add(candidates[0].oid); - return { ...outcome, orderId: candidates[0].oid.toString() }; - }); - } catch (error) { - this.#deps.debugLogger.log( - 'Could not reconcile TP/SL placement order IDs', - { - error: ensureError( - error, - 'HyperLiquidProvider.reconcileTpslOrderPlacementOutcomes', - ).message, - symbol: params.symbol, - }, - ); - return params.outcomes; + } + correlated.push(resolved); } + const ids = correlated.flatMap((outcome) => + outcome.orderId ? [outcome.orderId] : [], + ); + return correlated.map((outcome) => + outcome.orderId && ids.filter((id) => id === outcome.orderId).length > 1 + ? { state: 'unknown' } + : outcome, + ); } /** @@ -9696,8 +9767,8 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param orders - The SDK order payloads about to be submitted. * @param generatedCloids - Cloids this package generated for these orders and - * may therefore re-stamp. Only the Scale ladder supplies any; every other path - * either has no cloid or carries the caller's own. + * may therefore re-stamp. Scale ladders and position TP/SL supply these; + * other paths have no cloid or carry the caller's own. * @param marksSubscriptionCloid - Overrides the live fee resolution for a * chase replacement, which runs after that resolution has been cleared. * @returns The same payloads, with cloids marked when subscription won. @@ -9713,6 +9784,8 @@ export class HyperLiquidProvider implements PerpsProvider { const marks = marksSubscriptionCloid ?? this.#isSubscriptionFeeSource(); if (!marks) { // Any other source leaves the id exactly as the caller built it. + // Position TP/SL already reserves the subscription layout with flag00; + // the program marker alone never implies a fee reduction. return orders; } @@ -10760,7 +10833,38 @@ export class HyperLiquidProvider implements PerpsProvider { async updatePositionTPSL( params: UpdatePositionTPSLParams, ): Promise { + const expectedPosition = + params.expectedPosition === undefined + ? undefined + : { ...params.expectedPosition }; try { + if ( + params.partialPairLinkage !== undefined && + (params.partialPairLinkage !== 'independent' || + !params.takeProfitPrice || + !params.stopLossPrice || + (params.takeProfitSize === undefined && + params.stopLossSize === undefined)) + ) { + throw new Error( + 'Hyperliquid partial pair linkage requires an independently sized TP/SL pair', + ); + } + + const lifecycle = this.#lifecycleGeneration; + const isTestnet = this.#clientService.isTestnetMode(); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const assertScope = async (): Promise => { + const current = await this.#walletService.getUserAddressWithDefault(); + this.#assertProviderLifecycleCurrent(lifecycle, 'updatePositionTPSL'); + if ( + current.toLowerCase() !== userAddress.toLowerCase() || + this.#clientService.isTestnetMode() !== isTestnet + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + }; + await assertScope(); this.#deps.debugLogger.log('Updating position TP/SL:', params); const { @@ -10806,6 +10910,8 @@ export class HyperLiquidProvider implements PerpsProvider { ); } + await assertScope(); + assertExpectedPosition(expectedPosition, position); if (!position) { throw new Error(PERPS_ERROR_CODES.POSITION_NOT_FOUND); } @@ -10834,8 +10940,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Holding the exchange client reference is not itself a write; it is only // used below, after the trading setup has run. const infoClient = this.#clientService.getInfoClient(); - const exchangeClient = this.#clientService.getExchangeClient(); - const userAddress = await this.#walletService.getUserAddressWithDefault(); + await assertScope(); // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(symbol); @@ -10954,7 +11059,6 @@ export class HyperLiquidProvider implements PerpsProvider { // OPTIMIZATION: Use WebSocket cache first (0 weight), fall back to single-DEX REST (20 weight) // Previously: queryUserDataAcrossDexs queried ALL DEXs (20 weight × N DEXs = 40+ weight) let cancelRequests: ExchangeCancelRequest[] = []; - const orderIdsBeforePlacement = new Set(); const restorablePositionTpslOrders: RestorableTpslOrder[] = []; const restorableStandaloneTpslOrders: RestorableTpslOrder[] = []; @@ -11060,9 +11164,6 @@ export class HyperLiquidProvider implements PerpsProvider { user: userAddress, dex: dexName ?? undefined, }); - openOrders.forEach((order) => - orderIdsBeforePlacement.add(order.oid.toString()), - ); // Orders that belong to a pending parent order (normalTpsl children) are // also listed at the top level, so collect their IDs to exclude them: @@ -11099,9 +11200,6 @@ export class HyperLiquidProvider implements PerpsProvider { 'Using WebSocket cache for TP/SL orders lookup', { cachedOrdersCount: cachedOrders.length }, ); - cachedOrders.forEach((order) => - orderIdsBeforePlacement.add(order.orderId), - ); // Filter using normalized Order type properties, matching the REST fallback criteria: // - symbol matches @@ -11181,6 +11279,68 @@ export class HyperLiquidProvider implements PerpsProvider { orders.push(slOrder); } + const generatedCloids = new Set(); + for (const order of orders) { + order.c = createUnmarkedProtectionCloid(generatedCloids); + } + const assertCurrentPosition = async (): Promise => { + await assertScope(); + if (expectedPosition) { + const state = await infoClient.clearinghouseState({ + user: userAddress, + ...(dexName ? { dex: dexName } : {}), + }); + await assertScope(); + const current = state.assetPositions.find( + (entry) => entry.position.coin === symbol, + ); + assertExpectedPosition( + expectedPosition, + current + ? { + size: current.position.szi, + entryPrice: current.position.entryPx ?? '', + } + : undefined, + ); + } + }; + await assertCurrentPosition(); + // These operation-local reads run inside the SDK signing semaphore. + // The extra round trips deliberately serialize with the signed write, + // preventing stale account-shape/position dispatch after wallet prompts. + const assertWritableScope = async (): Promise => { + await assertScope(); + const isMultiSig = await this.#isHyperliquidMultiSigAccount( + userAddress, + infoClient, + ); + await assertScope(); + if (isMultiSig) { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); + } + }; + let cancelGuardFailure: unknown; + const exchangeClient = this.#clientService.getExchangeClient(async () => { + try { + await assertWritableScope(); + await assertCurrentPosition(); + } catch (error) { + cancelGuardFailure = error; + throw error; + } + }); + const recoveryExchangeClient = + this.#clientService.getExchangeClient(assertWritableScope); + let replacementMayHaveDispatched = false; + const replacementExchangeClient = this.#clientService.getExchangeClient( + async () => { + await assertWritableScope(); + await assertCurrentPosition(); + replacementMayHaveDispatched = true; + }, + ); + const rollbackRequiresBuilderFee = [ ...restorablePositionTpslOrders, ...restorableStandaloneTpslOrders, @@ -11200,6 +11360,13 @@ export class HyperLiquidProvider implements PerpsProvider { ? await this.#getBuilderOrderContext(builderFeeSetupContext) : undefined; + const sentOrders = this.#applySubscriptionCloid(orders, generatedCloids); + if ( + new Set(sentOrders.map((order) => order.c)).size !== sentOrders.length + ) { + throw new Error('Duplicate final TP/SL client order identity'); + } + const restoreCancelledProtection = async ( cancelledOrderIds: Set, ): Promise => { @@ -11226,74 +11393,84 @@ export class HyperLiquidProvider implements PerpsProvider { continue; } + const restorationOrders = entries.map((entry) => ({ + ...entry.order, + c: createUnmarkedProtectionCloid(generatedCloids), + })); + const sentRestorationOrders = this.#applySubscriptionCloid( + restorationOrders, + new Set(restorationOrders.map((order) => order.c)), + ); + let statuses: unknown[]; + let restorationFailure: Error | undefined; try { - const result = await exchangeClient.order({ - orders: this.#applySubscriptionCloid( - entries.map((entry) => entry.order), - ), + // Restore the exact pre-call reduce-only intent. Scope and account + // shape are still probed after signing, but the requested new + // protection's position precondition does not apply to rollback. + await assertScope(); + const result = await recoveryExchangeClient.order({ + orders: sentRestorationOrders, grouping: protection.grouping, ...(entries.some((entry) => entry.chargesMetamaskBuilderFee) && builderOrderContext && { builder: builderOrderContext }), }); - const statuses = result.response?.data?.statuses ?? []; - const rawOutcomes = statuses - .slice(0, entries.length) - .map((status) => this.#readTpslOrderPlacementOutcome(status)); - const outcomes = await this.#reconcileTpslOrderPlacementOutcomes({ - outcomes: rawOutcomes, - orders: entries.map((entry) => entry.order), - previousOrderIds: new Set([ - ...orderIdsBeforePlacement, - ...Array.from(cancelledOrderIds, String), - ]), - dexName, - symbol, - }); - restoredOrderIds.push( - ...outcomes.flatMap((outcome) => - outcome.orderId ? [outcome.orderId] : [], - ), - ); - if ( - result.status !== 'ok' || - statuses.length !== entries.length || - outcomes.some( - (outcome) => - outcome.state === 'rejected' || outcome.state === 'unknown', - ) - ) { - success = false; - this.#deps.logger.error( - new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED), - this.#getErrorContext( - 'updatePositionTPSL > restoreCancelledProtection', - { symbol, grouping: protection.grouping, statuses }, - ), - ); - } + statuses = result.response?.data?.statuses ?? []; } catch (error) { + const failure = await this.#classifyProtectionWriteError(error); + restorationFailure = failure; + statuses = + this.#readProtectionErrorStatuses(error) ?? + (this.#isDefinitiveProtectionRejection(error) + ? entries.map(() => ({ error: failure.message })) + : []); + this.#deps.debugLogger.log( + 'Protection restoration requires reconciliation', + { symbol, error: failure.message }, + ); + } + await assertScope(); + const outcomes = await this.#reconcileTpslOrderPlacementOutcomes({ + outcomes: statuses + .slice(0, entries.length) + .map((status) => this.#readTpslOrderPlacementOutcome(status)), + orders: sentRestorationOrders, + userAddress, + infoClient, + assertScope, + symbol, + }); + await assertScope(); + restoredOrderIds.push( + ...outcomes.flatMap((outcome) => + outcome.orderId ? [outcome.orderId] : [], + ), + ); + if ( + outcomes.some( + (outcome) => + outcome.state === 'rejected' || outcome.state === 'unknown', + ) + ) { success = false; - // A signer that could not sign is retryable, not a defect; the - // caller still learns the protection was lost. - if ( - !(await this.#handleSignerFailure(error, 'updatePositionTPSL', { - symbol, - grouping: protection.grouping, - })) - ) { + const signerFailure = + restorationFailure && + (await this.#isSignerFailure(restorationFailure)); + await assertScope(); + if (!signerFailure) { this.#deps.logger.error( - ensureError( - error, - 'HyperLiquidProvider.updatePositionTPSL.restoreCancelledProtection', - ), - this.#getErrorContext( - 'updatePositionTPSL > restoreCancelledProtection', - { symbol, grouping: protection.grouping }, - ), + restorationFailure ?? + new Error(PERPS_ERROR_CODES.TPSL_PROTECTION_LOST), + this.#getErrorContext('updatePositionTPSL', { + symbol, + stage: 'restoration', + protectionStatus: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: restoredOrderIds, + }), ); } } } + await assertScope(); return { restoredOrderIds, success }; }; @@ -11308,9 +11485,18 @@ export class HyperLiquidProvider implements PerpsProvider { // Cancel before placing for both position-bound and standalone partial // triggers. A place-first partial update leaves both trigger sets live // during the cancellation round trip and can reduce more than requested. + await assertCurrentPosition(); const oldCancellation = await this.#cancelOrderRequestBatch( exchangeClient, cancelRequests, + async (error) => { + if (cancelGuardFailure !== undefined) { + throw await this.#classifyProtectionWriteError(cancelGuardFailure); + } + if (isHyperLiquidMultiSigRequiredError(error)) { + throw await this.#classifyProtectionWriteError(error); + } + }, ); // The signer could not sign the cancel. With nothing cancelled the old // protection is still in place, and a clear keeps only what it could @@ -11326,6 +11512,8 @@ export class HyperLiquidProvider implements PerpsProvider { }); } + await assertScope(); + // Clearing has no replacement batch to preserve. A partial cancellation // is reported so the caller can retry the same clear operation. if (orders.length === 0) { @@ -11337,6 +11525,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); return { success: true, + childOrderIds: [], // No orderId since we only cancelled orders, didn't place new ones }; } @@ -11364,6 +11553,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); } + await assertScope(); return createProtectionLostResult(possiblyLiveOrderIds); } oldCancellation.cancelledOrderIds.forEach((orderId) => @@ -11407,42 +11597,57 @@ export class HyperLiquidProvider implements PerpsProvider { }); } - let result: Awaited>; + let placementStatuses: unknown[]; + let placementFailure: Error | undefined; + let responseAccepted = false; try { - result = await exchangeClient.order({ - orders: this.#applySubscriptionCloid(orders), + // Old protection may already be cancelled. A failed position read + // belongs to recovery, which reports loss if safe restoration is + // impossible instead of returning a pre-mutation refresh error. + await assertCurrentPosition(); + const result = await replacementExchangeClient.order({ + orders: sentOrders, grouping: isPartialTpsl ? 'na' : 'positionTpsl', ...(replacementChargesMetamaskBuilderFee && builderOrderContext && { builder: builderOrderContext }), }); + placementStatuses = result.response?.data?.statuses ?? []; + responseAccepted = result.status === 'ok'; } catch (error) { - // Classify first, so a rejected agent is dropped (and reported) before - // the restoration signs. - const signerFailure = await this.#classifySignerFailure(error); - const restoration = await restoreCancelledProtection( - confirmedCancelledOldOrderIds, - ); - if (!restoration.success) { - return createProtectionLostResult(restoration.restoredOrderIds); - } - if (signerFailure) { - this.#logRetryableSignerFailure('updatePositionTPSL', { symbol }); - return createErrorResult(signerFailure, { + // Drop and report a rejected agent before restoration signs. + placementFailure = await this.#classifyProtectionWriteError(error); + await assertScope(); + const statuses = this.#readProtectionErrorStatuses(error); + if (statuses !== undefined) { + placementStatuses = statuses; + } else if ( + replacementMayHaveDispatched && + !this.#isDefinitiveProtectionRejection(error) + ) { + // Arbitrary transport/HTTP failures do not prove non-processing. + placementStatuses = []; + } else { + const restoration = await restoreCancelledProtection( + confirmedCancelledOldOrderIds, + ); + if (!restoration.success) { + return createProtectionLostResult(restoration.restoredOrderIds); + } + return createErrorResult(placementFailure, { success: false, - ...(restoration.restoredOrderIds.length > 0 && { - childOrderIds: restoration.restoredOrderIds, - }), + ...(restoration.restoredOrderIds.length > 0 + ? { childOrderIds: restoration.restoredOrderIds } + : {}), }); } - throw error; } - const placementStatuses = result.response?.data?.statuses ?? []; + await assertScope(); const initialPlacementOutcomes = placementStatuses .slice(0, orders.length) .map((status) => this.#readTpslOrderPlacementOutcome(status)); const placementAccepted = - result.status === 'ok' && + responseAccepted && placementStatuses.length === orders.length && initialPlacementOutcomes.every( (outcome) => @@ -11452,24 +11657,71 @@ export class HyperLiquidProvider implements PerpsProvider { ); if (placementAccepted) { + // Only response-correlated IDs form a receipt. A waitingForTrigger + // acknowledgement without an ID cannot be attributed from book changes. + const childOrderIds: string[] = []; + let identitiesValid = true; + for (const [index, outcome] of initialPlacementOutcomes.entries()) { + const cloid = sentOrders[index]?.c; + let { orderId } = outcome; + if (cloid === undefined) { + identitiesValid = false; + continue; + } + // One exact-ID lookup verifies immediate correlation. An unknown + // index is uncertainty, not a reason to repeat the same read in a tight loop. + await assertScope(); + try { + const status = await infoClient.orderStatus({ + user: userAddress, + oid: cloid, + }); + await assertScope(); + if (status.status === 'order') { + const row = status.order.order; + if ( + row.coin !== symbol || + row.cloid?.toLowerCase() !== cloid.toLowerCase() || + !Number.isSafeInteger(row.oid) || + row.oid <= 0 || + (orderId !== undefined && orderId !== String(row.oid)) + ) { + identitiesValid = false; + } else { + orderId = String(row.oid); + } + } + } catch { + // Read failure cannot invent an ID; stale scopes cannot return receipts. + await assertScope(); + } + if (orderId !== undefined) { + childOrderIds.push(orderId); + } + } + const completeReceipt = + identitiesValid && + childOrderIds.length === orders.length && + childOrderIds.every((id) => Number(id) > 0) && + new Set(childOrderIds).size === childOrderIds.length; return { success: true, orderId: 'TP/SL orders placed', + ...(completeReceipt ? { childOrderIds } : {}), }; } const placementOutcomes = await this.#reconcileTpslOrderPlacementOutcomes( { outcomes: initialPlacementOutcomes, - orders, - previousOrderIds: new Set([ - ...orderIdsBeforePlacement, - ...Array.from(confirmedCancelledOldOrderIds, String), - ]), - dexName, + orders: sentOrders, + userAddress, + infoClient, + assertScope, symbol, }, ); + await assertScope(); const restingReplacementOrderIds = placementOutcomes.flatMap((outcome) => (outcome.state === 'resting' || outcome.state === 'waitingForTrigger') && @@ -11480,22 +11732,53 @@ export class HyperLiquidProvider implements PerpsProvider { const filledReplacementOrderIds = placementOutcomes.flatMap((outcome) => outcome.state === 'filled' && outcome.orderId ? [outcome.orderId] : [], ); + const inFlightOrderIds = placementOutcomes.flatMap((outcome) => + outcome.state === 'unknown' && outcome.orderId ? [outcome.orderId] : [], + ); const hasUnresolvedWaitingTrigger = placementOutcomes.some( (outcome) => - outcome.state === 'waitingForTrigger' && + (outcome.state === 'waitingForTrigger' || + outcome.state === 'unknown') && outcome.orderId === undefined, ); + await assertScope(); + let cleanupFailure: Error | undefined; const remainingReplacementIds = await this.#cancelOrderRequests( - exchangeClient, + recoveryExchangeClient, restingReplacementOrderIds.map((orderId) => ({ a: assetId, o: Number(orderId), })), + async (error) => { + if ( + isHyperLiquidMultiSigRequiredError(error) || + (error instanceof Error && + error.message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED) + ) { + // Old protection has already changed. Classification must not + // throw away this batch's still-live replacement identities. + cleanupFailure = await this.#classifyProtectionWriteError(error); + } + }, ); + await assertScope(); const recoverableOrderIds = [ ...filledReplacementOrderIds, + ...inFlightOrderIds, ...remainingReplacementIds.map(String), ]; + if (cleanupFailure && recoverableOrderIds.length > 0) { + this.#deps.logger.error( + cleanupFailure, + this.#getErrorContext('updatePositionTPSL', { + symbol, + stage: 'cleanup', + protectionStatus: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: recoverableOrderIds, + }), + ); + return createProtectionLostResult(recoverableOrderIds); + } if (hasUnresolvedWaitingTrigger) { return createProtectionLostResult(recoverableOrderIds); } @@ -11506,24 +11789,30 @@ export class HyperLiquidProvider implements PerpsProvider { if (!restoration.success) { return createProtectionLostResult(restoration.restoredOrderIds); } - } - // A filled replacement may have changed or closed the position, while - // an uncancelled replacement may still protect it. Restoring the old - // whole-position triggers in either case risks stale or duplicate - // protection, so return those IDs for caller reconciliation instead. - if (recoverableOrderIds.length > 0) { - if (!isPartialTpsl) { - return createProtectionLostResult(recoverableOrderIds); - } return createErrorResult( - new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED), + placementFailure ?? new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED), { success: false, - childOrderIds: recoverableOrderIds, + ...(restoration.restoredOrderIds.length > 0 + ? { childOrderIds: restoration.restoredOrderIds } + : {}), }, ); } - throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + // A filled replacement may have changed or closed the position, while + // an uncancelled replacement may still protect it. Restoring the old + // whole-position triggers in either case risks stale or duplicate + // protection, so return those IDs for caller reconciliation instead. + if (!isPartialTpsl) { + return createProtectionLostResult(recoverableOrderIds); + } + return createErrorResult( + new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED), + { + success: false, + childOrderIds: recoverableOrderIds, + }, + ); } catch (error) { const signerFailure = await this.#handleSignerFailure( error, @@ -14222,6 +14511,12 @@ export class HyperLiquidProvider implements PerpsProvider { params: OrderParams, ): Promise<{ isValid: boolean; error?: string }> { try { + if (params.expectedScaleLadder !== undefined) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE, + }; + } // Basic parameter validation const basicValidation = validateOrderParams({ coin: params.symbol, diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 5e83cafa9e0..2728da6216b 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -15,30 +15,61 @@ */ import type { CaipAccountId } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import type { CandlePeriod } from '../constants/chartConfig.js'; import { computeLighterMinOrderSize, + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_NATIVE_PROBE_MAX_NOTIONAL, + LIGHTER_NATIVE_PROBE_PAGE_LIMIT, + LIGHTER_NATIVE_PROBE_PAGE_SIZE, + LIGHTER_CHASE_DEFAULT_INTERVAL_MS, + LIGHTER_CHASE_DEFAULT_DURATION_MS, + LIGHTER_CHASE_DEFAULT_REPRICINGS, + LIGHTER_CHASE_DEFAULT_DISTANCE_BPS, + LIGHTER_CHASE_MIN_INTERVAL_MS, + LIGHTER_CHASE_MAX_DURATION_MS, + LIGHTER_CHASE_MAX_REPRICINGS, + LIGHTER_CHASE_MAX_DISTANCE_BPS, + LIGHTER_CHASE_QUOTE_MAX_AGE_MS, + LIGHTER_POST_ONLY_QUOTE_MAX_AGE_MS, fromLighterInteger, getLighterChainId, getLighterTransactionOutcome, LIGHTER_RESOLUTION_MS, + LIGHTER_MAX_WIRE_PRICE, + LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT, LIGHTER_SUPPORTED_RESOLUTIONS, LIGHTER_DEFAULT_API_KEY_INDEX, + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_TX_EXPIRY_SLACK_MS, + LIGHTER_MAX_TRADING_API_KEY_INDEX, + LIGHTER_TRADING_API_KEY_COUNT, + LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS, + LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS, + LIGHTER_KEY_REGISTRATION_VISIBILITY_MAX_ATTEMPTS, + LIGHTER_FILL_REPLAY_LIMIT, LIGHTER_NO_TRIGGER_PRICE, LIGHTER_ORDER_EXPIRY_NONE, LIGHTER_ORDER_TYPE_LIMIT, LIGHTER_ORDER_TYPE_MARKET, LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME, + LIGHTER_TIME_IN_FORCE_POST_ONLY, getLighterWsEndpoint, LIGHTER_PRICE_POLLING_INTERVAL_MS, LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, LIGHTER_BRIDGE_CONFIG, LIGHTER_TX_TYPE_CANCEL_ORDER, + LIGHTER_TX_TYPE_MODIFY_ORDER, LIGHTER_TX_TYPE_CHANGE_PUB_KEY, LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER, + LIGHTER_GROUPING_ONE_TRIGGERS_THE_OTHER, + LIGHTER_GROUPING_ONE_TRIGGERS_OCO, LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT, LIGHTER_ORDER_TYPE_TAKE_PROFIT, + LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT, LIGHTER_TX_TYPE_CREATE_GROUPED_ORDERS, LIGHTER_TX_TYPE_CREATE_ORDER, LIGHTER_TX_TYPE_UPDATE_LEVERAGE, @@ -50,6 +81,8 @@ import { LIGHTER_USDC_ASSET_INDEX, LIGHTER_DATA_INTEGRITY_PREFIX, LIGHTER_MARGIN_METADATA_TTL_MS, + LIGHTER_SCALE_SETTLEMENT_WINDOW_MS, + LIGHTER_SCALE_SETTLEMENT_POLL_MS, parseLighterStrictDecimal, toLighterInteger, } from '../constants/lighterConfig.js'; @@ -57,15 +90,50 @@ import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; import type { PerpsControllerMessenger } from '../PerpsController.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { hasErrorInCauseChain } from '../services/causeChain.js'; +import { + LighterChaseService, + LighterChaseObservationPendingError, + toLighterChaseOrder, +} from '../services/LighterChaseService.js'; +import type { + LighterChaseIntent, + LighterChaseOwner, + LighterChaseIo, + LighterChaseRecord, + LighterChaseDispatch, + LighterChaseChild, +} from '../services/LighterChaseService.js'; import { convertKeysToCamelCase, LighterApiError, LighterClientService, } from '../services/LighterClientService.js'; +import { + requireSignedTxIdentity, + isLighterTxExpiry, +} from '../services/lighterDispatchIdentity.js'; +import { LighterTwapService } from '../services/LighterTwapService.js'; +import type { + LighterTwapOwner, + LighterTwapReadObservation, +} from '../services/LighterTwapService.js'; import { LighterWalletService } from '../services/LighterWalletService.js'; +import type { + ScaleOrderGroup, + GetScalePriceLadderParams, + PerpsScalePriceLadder, + ChaseOrder, + ChaseOrderStatus, + GetChaseOrderOwnershipParams, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, + PerpsChaseOrderOwnership, + PerpsChaseOrderDispatch, +} from '../types/index.js'; import { WebSocketConnectionState } from '../types/index.js'; import type { AccountState, + TwapOrder, AssetRoute, CandleData, CandleStick, @@ -78,6 +146,9 @@ import type { FeeCalculationParams, FeeCalculationResult, Funding, + DirectProviderOrderCapabilities, + DirectProviderOrderCapabilitiesUnavailableReason, + GetOrderCapabilitiesParams, GetAccountStateParams, GetFundingParams, GetHistoricalPortfolioParams, @@ -99,10 +170,17 @@ import type { OrderFill, OrderParams, OrderResult, + AttachedOrderGroup, + TriggerOrderType, PerpsMarginModeLock, PerpsMarketData, PerpsPlatformDependencies, PerpsProvider, + PerpsRecoveredDispatch, + PerpsPendingManualRecovery, + PerpsRecoveryVenueReview, + ResolveRecoveryProtectionParams, + PerpsRecoveryProtectionResult, PerpsReadOptions, Position, PositionModifyPreviewParams, @@ -129,8 +207,12 @@ import type { } from '../types/index.js'; import type { LighterApiOrder, + LighterEditableOrder, + LighterSignModifyOrderWireParams, + LighterRestTrade, LighterApiPosition, LighterAccountsByL1AddressResponse, + LighterAccountSummary, LighterAuthConfig, LighterTxLookupResponse, LighterTransferHistoryItem, @@ -162,10 +244,59 @@ import { adaptMarketDataFromLighter, adaptMarketFromLighter, adaptOrderFromLighter, + adaptOrderStatus, adaptPositionFromLighter, adaptPriceUpdateFromLighter, adaptPriceUpdateFromLighterWsStat, } from '../utils/lighterAdapter.js'; +import { + LIGHTER_ATTACHED_MAX_GROUPS, + LIGHTER_ATTACHED_HANDLE_PREFIX, + parseLighterAttachedGroups, + correlateLighterAttachedOrders, + toAttachedOrderGroup, +} from '../utils/lighterAttachedOrders.js'; +import type { LighterAttachedGroup } from '../utils/lighterAttachedOrders.js'; +import { + identifyLighterChaseChild, + readLighterChaseQuote, + reconcileLighterChaseChild, +} from '../utils/lighterChase.js'; +import { + LIGHTER_EDIT_JOURNAL_PREFIX, + decodeLighterModifyOrder, + lighterEditOrderId, + lighterNativeEditTarget, + lighterNativeEditResult, + observeLighterNativeEdit, + parseLighterNativeEditJournal, + prepareLighterNativeEdit, +} from '../utils/lighterNativeEdit.js'; +import type { LighterNativeEditJournal } from '../utils/lighterNativeEdit.js'; +import { + buildLighterScaleLadder, + captureExpectedScaleLadder, + assertExpectedScaleLadder, + normalizeLighterScalePrices, + parseLighterScaleGroups, + toLighterScaleGroup, + isLighterScaleTerminal, + LIGHTER_SCALE_PREFIX, + LIGHTER_SCALE_JOURNAL_PREFIX, + LIGHTER_SCALE_MAX_GROUPS, +} from '../utils/lighterScaleOrders.js'; +import type { + LighterScaleGroup, + LighterScaleRung, +} from '../utils/lighterScaleOrders.js'; +import { prepareLighterTwapOrder } from '../utils/lighterTwap.js'; +import { identifyLighterTwapParent } from '../utils/lighterTwapReconciliation.js'; +import { + isLimitExecutionOrderType, + isTriggerOrderType, + TRIGGER_ORDER_TYPES, +} from '../utils/orderTypes.js'; +import { assertExpectedPosition } from '../utils/positionProtection.js'; type LighterFillQuery = GetOrderFillsParams & { symbol?: string; @@ -174,6 +305,7 @@ type LighterFillQuery = GetOrderFillsParams & { type LighterMarginMetadata = { marketId: number; minInitial?: number; + defaultInitial?: number; maintenance?: number; lastTradePrice?: number; }; @@ -188,6 +320,22 @@ const NOOP_UNSUBSCRIBE = (): void => undefined; const LIGHTER_INACTIVE_HISTORY_ROW_LIMIT = 10_000; +/** Default Lighter execution protection, in basis points (5%). */ +const LIGHTER_DEFAULT_SLIPPAGE_BPS = 500; + +/** Maximum durable position-protection ownership entries per account market. */ +const LIGHTER_TPSL_OWNERSHIP_MAX_ORDERS = 256; + +/** Single-page history bound used when reclaiming expired protection ownership. */ +const LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE = 100; +const LIGHTER_ATTACHED_HISTORY_PAGE_LIMIT = + LIGHTER_INACTIVE_HISTORY_ROW_LIMIT / LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE; +const LIGHTER_ATTACHED_HISTORY_TIME_SLACK_MS = 30_000; +type LighterAttachedOrderSnapshot = { + rows: LighterApiOrder[]; + complete: boolean; +}; + const deriveLighterMaxLeverage = ( minInitialMarginFraction: number | undefined, market: string | number, @@ -314,7 +462,7 @@ const snapToLighterSizeGrid = ( * @returns Whether the order rests as a maker order. */ const isLighterMakerOrder = (params: OrderParams): boolean => - params.orderType === 'limit' && params.timeInForce !== 'IOC'; + isLimitExecutionOrderType(params.orderType) && params.timeInForce !== 'IOC'; /** * Parse a candle field without JavaScript's null/boolean/blank coercions. @@ -356,9 +504,6 @@ const toFiniteCandle = (candle: LighterCandle): CandleStick | null => { }; }; -/** 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, @@ -374,12 +519,16 @@ type TpslCreateAttempt = { attemptId: number; /** See TpslCancelAttempt.terminalStatus. */ terminalStatus?: number; + /** Current-pass exact-hash absence after expiry; never persisted or reused. */ + neverLanded?: true; /** 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[]; + /** Signed absolute ORDER expiries, aligned with clientIds; legacy may omit. */ + orderExpiries?: number[]; /** The signed transaction hash (known BEFORE submission). */ txHash: string; /** @@ -501,13 +650,187 @@ type LighterNonceLedgerDoc = { recovered: LighterRecoveredDispatch[]; }; +const TPSL_GUARDED_REMOVAL_REVIEW_REASON = + 'Protection removal requires review because its position precondition failed or the guarded session ended; review surviving orders before changing protection'; + +/** Durable identity of a key registration that must settle before allocation. */ +type LighterPendingKeyRegistration = { + version: 1; + accountIndex: number; + apiKeyIndex: number; + publicKey: string; + txHash: string; + nonce: number; + expiresAt: number; + accepted: boolean; +}; + +/** + * Validate persisted replacement groups retained across signer changes. + * + * @param value - Persisted groups. + * @returns Whether every group contains valid Lighter client order IDs. + */ +const isRecoveryGroups = (value: unknown): value is number[][] => + Array.isArray(value) && + value.length <= 64 && + value.every( + (group) => + Array.isArray(group) && + group.length > 0 && + group.length <= 2 && + group.every( + (id) => + typeof id === 'number' && + Number.isSafeInteger(id) && + id > 0 && + id < 2 ** 48, + ), + ); + +type TpslRecoverySuccessor = { + version: 1; + sourceSettlementKey: string; + sourceOperationId: string; + successorSettlementKey: string; + successorOperationId: string; + state: 'prepared' | 'pending' | 'failed' | 'settled'; + ownedOrderIds: string[]; + retainedReplacementGroups?: number[][]; +}; + +type PartialTpslIntent = { + positionSign: 1 | -1; + positionWireSize: number; + sizeDecimals: number; + orders: LighterCreateOrderWireParams[]; +} & ( + | { version: 1; linkage: 'single' | 'oco' } + | { + version: 2; + linkage: 'independent'; + positionSize: string; + requestedSizes: (string | null)[]; + } +); + +/** + * Validate persisted fixed coverage before it can authorize recovery. + * + * @param value - Untrusted persisted intent. + * @returns Whether the intent contains valid fixed wire orders. + */ +const isPartialTpslIntent = (value: unknown): value is PartialTpslIntent => { + if (typeof value !== 'object' || value === null) { + return false; + } + const intent = value as Record; + const { orders: rawOrders } = intent; + const orders: unknown[] = Array.isArray(rawOrders) ? rawOrders : []; + return ( + (intent.version === 1 || + (intent.version === 2 && + intent.linkage === 'independent' && + typeof intent.positionSize === 'string' && + new BigNumber(intent.positionSize).isFinite() && + new BigNumber(intent.positionSize).gt(0) && + new BigNumber(intent.positionSize) + .shiftedBy(Number(intent.sizeDecimals)) + .integerValue(BigNumber.ROUND_DOWN) + .eq(Number(intent.positionWireSize)) && + Number(intent.positionWireSize) < 2 ** 48 && + Array.isArray(intent.requestedSizes) && + intent.requestedSizes.length === 2 && + intent.requestedSizes.some((size) => size !== null) && + intent.requestedSizes.every((size: unknown, index: number) => { + if ( + size !== null && + (typeof size !== 'string' || + !/^(?:\d+(?:\.\d*)?|\.\d+)$/u.test(size)) + ) { + return false; + } + const amount = new BigNumber(size ?? (intent.positionSize as string)); + const wire = amount + .shiftedBy(Number(intent.sizeDecimals)) + .integerValue(BigNumber.ROUND_DOWN); + const order = orders[index]; + return ( + amount.isFinite() && + amount.gt(0) && + amount.lte(intent.positionSize as string) && + Array.isArray(order) && + typeof order[2] === 'string' && + wire.eq(order[2]) && + wire.lt(2 ** 48) && + Number(order[3]) < 2 ** 32 && + Number(order[8]) < 2 ** 32 && + order[5] === + (index === 0 + ? LIGHTER_ORDER_TYPE_TAKE_PROFIT + : LIGHTER_ORDER_TYPE_STOP_LOSS) + ); + }))) && + (intent.positionSign === 1 || intent.positionSign === -1) && + typeof intent.positionWireSize === 'number' && + Number.isSafeInteger(intent.positionWireSize) && + intent.positionWireSize > 0 && + typeof intent.sizeDecimals === 'number' && + Number.isInteger(intent.sizeDecimals) && + intent.sizeDecimals >= 0 && + intent.sizeDecimals <= 18 && + Array.isArray(rawOrders) && + ((intent.version === 1 && + intent.linkage === 'single' && + orders.length === 1) || + (intent.version === 1 && + intent.linkage === 'oco' && + orders.length === 2) || + (intent.version === 2 && + intent.linkage === 'independent' && + orders.length === 2)) && + orders.every( + (order: unknown) => + Array.isArray(order) && + order.length === 10 && + Number.isSafeInteger(order[0]) && + order[0] >= 0 && + Number.isSafeInteger(order[1]) && + order[1] > 0 && + order[1] < 2 ** 48 && + [2, 3, 8].every( + (index) => + typeof order[index] === 'string' && + /^[1-9]\d*$/u.test(order[index]) && + Number.isSafeInteger(Number(order[index])), + ) && + Number(order[2]) <= Number(intent.positionWireSize) && + order[4] === (intent.positionSign === 1 ? 1 : 0) && + (order[5] === LIGHTER_ORDER_TYPE_STOP_LOSS || + order[5] === LIGHTER_ORDER_TYPE_TAKE_PROFIT) && + order[6] === LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL && + order[7] === 1 && + order[9] === LIGHTER_ORDER_EXPIRY_NONE, + ) && + (orders.length === 1 || + (Array.isArray(orders[0]) && + Array.isArray(orders[1]) && + orders[0][0] === orders[1][0] && + orders[0][1] !== orders[1][1] && + (intent.linkage === 'independent' || orders[0][2] === orders[1][2]) && + orders[0][5] !== orders[1][5])) + ); +}; + /** * 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. + * parks failed replacements or guarded removals for explicit user action. + * A guarded removal is non-resumable from its first durable attempt. */ type TpslJournalState = { + partialIntent?: PartialTpslIntent; attempts: TpslAttempt[]; recordedAt: number; /** @@ -516,6 +839,10 @@ type TpslJournalState = { * STALE snapshot can never erase a newer operation's journal. */ operationId: string; + /** Exact manual source operation selected by the explicit successor. */ + sourceRecoveryOperationId?: string; + sourceRecoverySettlementKey?: string; + retainedReplacementGroups?: number[][]; /** When the OPERATION began (immutable; `recordedAt` moves per write). */ createdAt: number; /** @@ -538,7 +865,9 @@ type TpslJournalState = { * 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. + * intent from the user resolves it. Guarded removals start in this state + * before dispatch: only the foreground session may cancel further orders; + * recovery reconciles attempted cancels and preserves surviving protection. */ phase: 'creating' | 'cancelling' | 'manual'; /** @@ -549,6 +878,14 @@ type TpslJournalState = { priorTriggers: TpslPriorTrigger[]; }; +/** Durable identities of Core-created protection, independent of position size. */ +type ManagedTpslOrder = { + clientId: string; + orderId: string | null; + /** Signed absolute order expiry (ms); unknown for legacy records. */ + orderExpiry?: number; +}; + /** * DURABLE manual-recovery record, SEPARATE from the settlement journal: * parking releases the journal slot (so a successor protection intent @@ -556,6 +893,7 @@ type TpslJournalState = { * SUCCEEDS — a failed successor must never erase the warning. */ type TpslManualRecovery = { + partialIntent?: PartialTpslIntent; settlementKey: string; symbol: string; /** Human-readable cause of the parked state. */ @@ -569,56 +907,74 @@ type TpslManualRecovery = { 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; /** Maximum durable recovered-dispatch outcomes retained per nonce ledger. */ const LIGHTER_RECOVERED_DISPATCH_LIMIT = 32; +/** Bounded native testnet probes retain a one-percent slippage default. */ +const LIGHTER_NATIVE_PROBE_DEFAULT_SLIPPAGE = 0.01; /** Maximum durable TP/SL manual-recovery obligations retained per network. */ const LIGHTER_TPSL_MANUAL_RECOVERY_LIMIT = 64; /** - * 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. + * Capture each exact client's signed absolute order expiry. Transaction expiry + * only bounds acceptance, so it cannot prove a resting order has ended. * * @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). + * @param signed.txInfo - Signed single or grouped wire payload. + * @param clientIds - Expected client identities, in journal order. + * @param zeroExpiryClientIds - Immediate parents whose exact signed expiry must be zero. + * @returns Index-aligned absolute order expiries, in milliseconds. */ -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', +const requireSignedOrderExpiries = ( + signed: { txInfo?: string }, + clientIds: number[], + zeroExpiryClientIds: readonly number[] = [], +): number[] => { + const invalid = (): Error => + new Error( + 'Lighter signing result carries no usable order expiry identity; refusing protection changes', ); - } - let expiresAt: unknown; + let payload: unknown; try { - expiresAt = (JSON.parse(signed.txInfo ?? '') as Record) - .ExpiredAt; + payload = JSON.parse(signed.txInfo ?? ''); } catch { - expiresAt = undefined; + throw invalid(); } - 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', - ); + if (typeof payload !== 'object' || payload === null) { + throw invalid(); } - return { txHash, expiresAt }; + const wire = payload as Record; + const rows = clientIds.length === 1 ? [wire] : wire.Orders; + if (!Array.isArray(rows) || rows.length !== clientIds.length) { + throw invalid(); + } + const expiries = new Map(); + for (const row of rows) { + if (typeof row !== 'object' || row === null) { + throw invalid(); + } + const { ClientOrderIndex: clientId, OrderExpiry: orderExpiry } = + row as Record; + if ( + typeof clientId !== 'number' || + !clientIds.includes(clientId) || + expiries.has(clientId) || + typeof orderExpiry !== 'number' || + !Number.isSafeInteger(orderExpiry) || + (zeroExpiryClientIds.includes(clientId) + ? orderExpiry !== 0 + : orderExpiry <= Date.now()) + ) { + throw invalid(); + } + expiries.set(clientId, orderExpiry); + } + return clientIds.map((clientId) => { + const expiry = expiries.get(clientId); + if (expiry === undefined) { + throw invalid(); + } + return expiry; + }); }; /** @@ -655,7 +1011,6 @@ const withProcessMutex = async ( if (processMutexTails.get(key) === settled) { processMutexTails.delete(key); } - return; }) .catch(() => undefined); return await run; @@ -776,9 +1131,9 @@ const parseTpslJournalPointer = ( * 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). + * ExpiredAt but NEVER the hash. Missing fields return null without throwing. + * Dispatch validation separately requires complete identities; extraction + * alone does not authorize submitting a partial signing result. * * @param signed - Bridge signing result. * @param signed.txHash - Signed transaction hash from the RESULT. @@ -822,12 +1177,40 @@ const nextAttemptIdFor = (journal: TpslJournalState): number => { return allocated; }; +/** Delay between exact margin transaction execution reads. */ +const LIGHTER_MARGIN_EXECUTION_POLL_MS = 150; + +/** Bound settlement reads without repeating the signed dispatch. */ +const LIGHTER_MARGIN_EXECUTION_ATTEMPTS = 10; + +/** Delay between exact native edit transaction and order settlement reads. */ +const LIGHTER_EDIT_SETTLE_POLL_MS = 150; + +/** Bound native edit settlement reads without signing or dispatching again. */ +const LIGHTER_EDIT_SETTLE_ATTEMPTS = 10; + /** 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; +/** Convert authoritative position percentage or default basis points to leverage. + * @param positionMargin - Position margin percentage, when a position exists. + * @param defaultInitial - Market initial margin in basis points. + * @returns Leverage, or NaN when unavailable. + */ +const scaleLeverageFromMargin = ( + positionMargin: string | undefined, + defaultInitial: number | undefined, +): number => { + let margin = defaultInitial === undefined ? null : defaultInitial / 100; + if (positionMargin !== undefined) { + margin = parseStrictDecimal(positionMargin); + } + return margin !== null && margin > 0 ? 100 / margin : Number.NaN; +}; + /** * Integerize a signer-bound PRICE (order price / trigger price): the * pinned lighter-go signer casts these to uint32 (web-wasm/main.go), so a @@ -963,10 +1346,10 @@ const lighterLeverageError = (leverage: number | undefined): string | null => { /** * Derive the protection/execution price a market order signs from its - * reference price — shared by placement and both validators so wire-range + * reference price, either a live price or a trigger level. 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 referencePrice - Fresh venue price or validated trigger level. * @param isBuy - Order side; buys protect above, sells below. * @param slippageFraction - Slippage tolerance (validated < 1). * @returns The slippage-adjusted execution price. @@ -984,6 +1367,297 @@ 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'; +const LIGHTER_TRIGGER_WIRE_TYPES: Readonly> = + Object.freeze({ + stop_market: LIGHTER_ORDER_TYPE_STOP_LOSS, + stop_limit: LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT, + take_profit_market: LIGHTER_ORDER_TYPE_TAKE_PROFIT, + take_profit_limit: LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT, + }); + +/** + * Keep unsupported post-only fields from silently changing caller intent. + * + * @param params - Caller order intent. + * @returns An unsupported field error, or null for ordinary ALO intent. + */ +const getLighterPostOnlyIntentError = (params: OrderParams): string | null => { + if (params.timeInForce !== 'ALO') { + return null; + } + if (params.orderType !== 'limit') { + return 'Lighter post-only requires an ordinary limit order'; + } + const fields: (keyof OrderParams)[] = [ + 'twapDuration', + 'twapRandomize', + 'scaleMinPrice', + 'scaleMaxPrice', + 'scaleNumOrders', + 'scaleSkew', + 'chaseIntervalMs', + 'chaseMaxDurationMs', + 'chaseMaxRepricings', + 'chaseMaxDistanceBps', + 'takeProfitPrice', + 'stopLossPrice', + 'takeProfitSize', + 'stopLossSize', + 'clientOrderId', + 'tpslLinkage', + 'grouping', + ]; + const unsupported = fields.find((field) => params[field] !== undefined); + return unsupported + ? `Lighter post-only does not support ${unsupported}` + : null; +}; + +/** + * Refuse trigger fields that the native standalone transaction cannot carry. + * + * @param params - Caller order intent. + * @returns A validation error, or null when the shape is supported. + */ +const getLighterTriggerIntentError = (params: OrderParams): string | null => { + if (!isTriggerOrderType(params.orderType)) { + return params.triggerPrice === undefined + ? null + : PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_NOT_SUPPORTED; + } + if (params.timeInForce !== undefined) { + return PERPS_ERROR_CODES.ORDER_TIME_IN_FORCE_NOT_SUPPORTED; + } + if ( + params.takeProfitPrice !== undefined || + params.stopLossPrice !== undefined + ) { + return PERPS_ERROR_CODES.ORDER_TRIGGER_TPSL_UNSUPPORTED; + } + const unsupportedFields = [ + 'takeProfitSize', + 'stopLossSize', + 'tpslLinkage', + 'grouping', + 'clientOrderId', + 'twapDuration', + 'twapRandomize', + 'scaleMinPrice', + 'scaleMaxPrice', + 'scaleNumOrders', + 'scaleSkew', + 'chaseIntervalMs', + 'chaseMaxDurationMs', + 'chaseMaxRepricings', + 'chaseMaxDistanceBps', + ] as const satisfies readonly (keyof OrderParams)[]; + const unsupported = unsupportedFields.find( + (field) => params[field] !== undefined, + ); + return unsupported + ? `Lighter standalone triggers do not support ${unsupported}` + : null; +}; + +/** + * Resolve the native trigger prices before any signer or account mutation. + * + * @param params - Standalone trigger intent. + * @param market - Venue price precision. + * @returns Sizing reference, execution protection and the exact trigger wire value. + */ +const resolveLighterTriggerPrices = ( + params: OrderParams, + market: LighterOrderBookMeta, +): { + referencePrice: number; + executionPrice: number; + triggerPriceInt: number; +} => { + if (params.triggerPrice === undefined || params.triggerPrice === '') { + throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_REQUIRED); + } + const triggerPrice = parseFinitePositive(params.triggerPrice); + if (triggerPrice === null) { + throw new Error(PERPS_ERROR_CODES.ORDER_TRIGGER_PRICE_POSITIVE); + } + const triggerPriceInt = toSignerWirePriceInteger( + triggerPrice, + market.supportedPriceDecimals, + ); + if ( + fromLighterInteger(triggerPriceInt, market.supportedPriceDecimals) !== + triggerPrice + ) { + throw new Error('Trigger price does not align with the Lighter price grid'); + } + if (isLimitExecutionOrderType(params.orderType)) { + const price = parseFinitePositive(params.price ?? ''); + if (price === null) { + throw new Error( + `Invalid limit price ${params.price}: must be a positive number`, + ); + } + return { referencePrice: price, executionPrice: price, triggerPriceInt }; + } + const slippageFraction = + params.maxSlippageBps === undefined + ? (params.slippage ?? LIGHTER_DEFAULT_SLIPPAGE_BPS / 10_000) + : params.maxSlippageBps / 10_000; + if ( + !Number.isFinite(slippageFraction) || + slippageFraction < 0 || + slippageFraction >= 1 + ) { + throw new Error( + `Invalid slippage tolerance ${slippageFraction * 10_000} bps: must be at least 0 and below 10000`, + ); + } + return { + referencePrice: triggerPrice, + executionPrice: deriveLighterExecutionPrice( + triggerPrice, + params.isBuy, + slippageFraction, + ), + triggerPriceInt, + }; +}; + +/** + * Preserve unsupported attached sizes and linkage as explicit errors. + * + * @param params - Caller intent. + * @returns Error or null for native full attached coverage. + */ +const getLighterAttachedIntentError = (params: OrderParams): string | null => { + const attached = + params.takeProfitPrice !== undefined || params.stopLossPrice !== undefined; + if ( + params.takeProfitSize !== undefined || + params.stopLossSize !== undefined + ) { + return 'Lighter attached orders do not support explicit child sizes'; + } + if (!attached) { + return (params.tpslLinkage !== undefined && + params.tpslLinkage !== 'none') || + (params.grouping !== undefined && params.grouping !== 'na') + ? 'Lighter attached linkage requires a TP or SL trigger' + : null; + } + if (params.orderType !== 'limit' && params.orderType !== 'market') { + return 'Lighter attached orders require a market or limit parent'; + } + if (params.reduceOnly) { + return 'Lighter attached orders require an opening parent'; + } + if ( + (params.tpslLinkage !== undefined && params.tpslLinkage !== 'order') || + (params.grouping !== undefined && params.grouping !== 'normalTpsl') + ) { + return 'Lighter attached orders only support order linkage'; + } + if (params.clientOrderId !== undefined) { + return 'Lighter attached orders do not support caller-provided client IDs'; + } + if ( + params.timeInForce !== undefined && + !['GTC', 'IOC'].includes(params.timeInForce) + ) { + return 'Lighter attached orders support GTC or IOC parents'; + } + return null; +}; + +/** + * Validate both native children before key setup or signing. + * + * @param params - Parent and attached intent. + * @param market - Fresh venue grid. + * @param referencePrice - Parent limit or current market price. + * @returns Unsigned children; their client IDs are assigned under the write lock. + */ +const resolveLighterAttachedChildren = ( + params: OrderParams, + market: LighterOrderBookMeta, + referencePrice: number, +): LighterCreateOrderWireParams[] => { + const children: LighterCreateOrderWireParams[] = []; + if ( + params.takeProfitPrice === undefined && + params.stopLossPrice === undefined + ) { + return children; + } + if ( + params.orderType === 'limit' && + fromLighterInteger( + toSignerWirePriceInteger(referencePrice, market.supportedPriceDecimals), + market.supportedPriceDecimals, + ) !== referencePrice + ) { + throw new Error( + 'Lighter attached parent price does not align with the price grid', + ); + } + for (const [price, orderType] of [ + [params.takeProfitPrice, 'take_profit_market'], + [params.stopLossPrice, 'stop_market'], + ] as const) { + if (price === undefined) { + continue; + } + const trigger = parseFinitePositive(price); + if (trigger === null) { + throw new Error('Lighter attached trigger prices must be positive'); + } + const takeProfit = orderType === 'take_profit_market'; + if ( + takeProfit === params.isBuy + ? trigger <= referencePrice + : trigger >= referencePrice + ) { + throw new Error( + 'Lighter attached trigger is on the wrong side of the parent reference price', + ); + } + const childIntent: OrderParams = { + ...params, + isBuy: !params.isBuy, + orderType, + triggerPrice: price, + }; + const resolved = resolveLighterTriggerPrices(childIntent, market); + children.push([ + market.marketId, + 1, + '0', + String( + toSignerWirePriceInteger( + resolved.executionPrice, + market.supportedPriceDecimals, + ), + ), + params.isBuy ? 1 : 0, + takeProfit + ? LIGHTER_ORDER_TYPE_TAKE_PROFIT + : LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + 1, + String(resolved.triggerPriceInt), + LIGHTER_ORDER_EXPIRY_NONE, + ]); + } + return children; +}; +/** No retained local key matches a current venue registration. */ +class LighterRecoveryReadAuthorityError extends Error { + constructor(message: string) { + super(message); + this.name = 'LighterRecoveryReadAuthorityError'; + } +} /** A definitive venue response that the selected wallet has no account. */ class LighterAccountNotFoundError extends Error { @@ -1055,6 +1729,11 @@ export class LighterProvider implements PerpsProvider { readonly #clientService: LighterClientService; + readonly #chaseService: LighterChaseService; + readonly #chaseTestnetProbe: boolean; + readonly #chaseTimers = new Map>(); + #chaseGeneration = 0; + readonly #walletService: LighterWalletService; readonly #messenger: PerpsControllerMessenger | null; @@ -1065,7 +1744,9 @@ export class LighterProvider implements PerpsProvider { readonly #isTestnet: boolean; - readonly #apiKeyIndex: number; + #apiKeyIndex: number; + + readonly #preferredApiKeyIndex: number; readonly #configuredAccountIndex: number | undefined; @@ -1141,6 +1822,11 @@ export class LighterProvider implements PerpsProvider { /** Merged live open orders from account_all_orders (keyed orderId). */ readonly #wsOrders: Map = new Map(); + #hasOrdersSnapshot = false; + + /** Last validated fill history for this wallet session, including empty. */ + #wsFills: OrderFill[] | null = null; + readonly #oiCapSubscribers: Set = new Set(); readonly #accountSubscribers: Set = new Set(); @@ -1174,10 +1860,19 @@ export class LighterProvider implements PerpsProvider { /** Signer session dedup. */ #signerReadyPromise: Promise | null = null; + /** Set only after key validation, registration and nonce reconciliation succeed. */ + #readyApiKeyIndex: number | null = null; + /** Cached auth token (deadline-managed). */ #authToken: { token: string; deadline: number } | null = null; + readonly #nativeTwapTestnetProbe: boolean; + readonly #nativeTwapService: LighterTwapService; + constructor(options: { + /** Explicit bounded testnet probe, advertised only with an injected signer. */ + chaseTestnetProbe?: boolean; + nativeTwapTestnetProbe?: boolean; isTestnet?: boolean; platformDependencies: PerpsPlatformDependencies; messenger?: PerpsControllerMessenger; @@ -1186,7 +1881,18 @@ export class LighterProvider implements PerpsProvider { webSocketCtor?: LighterWebSocketCtor | null; }) { this.#deps = options.platformDependencies; + this.#chaseService = new LighterChaseService({ + storage: this.#deps.diskCache, + }); + this.#chaseTestnetProbe = options.chaseTestnetProbe ?? false; + if (this.#chaseTestnetProbe && options.isTestnet === false) { + throw new Error('Lighter Chase probe is testnet-only'); + } this.#isTestnet = options.isTestnet ?? true; + if (options.nativeTwapTestnetProbe && !this.#isTestnet) { + throw new Error('Native TWAP probe mode is testnet-only'); + } + this.#nativeTwapTestnetProbe = options.nativeTwapTestnetProbe ?? false; this.#messenger = options.messenger ?? null; this.#signerBridge = options.signerBridge ?? null; this.#ensureSignerResetListener(); @@ -1201,6 +1907,7 @@ export class LighterProvider implements PerpsProvider { : options.webSocketCtor; this.#apiKeyIndex = options.lighterAuthConfig?.apiKeyIndex ?? LIGHTER_DEFAULT_API_KEY_INDEX; + this.#preferredApiKeyIndex = this.#apiKeyIndex; this.#configuredAccountIndex = options.lighterAuthConfig?.accountIndex; this.#clientService = new LighterClientService(this.#deps, { @@ -1211,6 +1918,21 @@ export class LighterProvider implements PerpsProvider { messenger: options.messenger, }); + this.#nativeTwapService = new LighterTwapService({ + storage: this.#deps.diskCache, + assertCurrent: (owner): void => { + this.#ensureSessionBinding(); + if ( + owner.wallet !== this.#boundAddress || + owner.network !== (this.#isTestnet ? 'testnet' : 'mainnet') || + owner.accountIndex !== this.#accountIndex || + this.#isDisconnected + ) { + throw new Error('Lighter TWAP ownership changed during operation'); + } + }, + }); + this.#deps.debugLogger.log('[LighterProvider] Constructor complete', { protocolId: this.protocolId, isTestnet: this.#isTestnet, @@ -1284,6 +2006,7 @@ export class LighterProvider implements PerpsProvider { // 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.#interruptChase(); this.#isDisconnected = true; this.#invalidateSessionState(); this.#removeSignerResetListener(); @@ -1441,6 +2164,8 @@ export class LighterProvider implements PerpsProvider { // client, drop the cached session so the next call re-runs setup // instead of failing forever against a resolved-but-dead session. return { + getRecoverableKeyIndices: bridge.getRecoverableKeyIndices?.bind(bridge), + getStoredKeyIndices: bridge.getStoredKeyIndices?.bind(bridge), createClient: async (params): Promise => { try { const result = await bridge.createClient(params); @@ -1480,6 +2205,7 @@ export class LighterProvider implements PerpsProvider { // started against the now-dead WASM client. this.#sessionGeneration += 1; this.#signerReadyPromise = null; + this.#readyApiKeyIndex = null; this.#authToken = null; this.#clearBridgeOwnership(); this.#deps.debugLogger.log( @@ -1571,9 +2297,12 @@ export class LighterProvider implements PerpsProvider { // 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.#interruptChase(); this.#sessionGeneration += 1; this.#accountIndex = null; + this.#apiKeyIndex = this.#preferredApiKeyIndex; this.#signerReadyPromise = null; + this.#readyApiKeyIndex = null; this.#authToken = null; this.#replaceSignerResetListener(); // #tpslUnsettled is NOT cleared: entries are keyed by @@ -1671,14 +2400,7 @@ export class LighterProvider implements PerpsProvider { } 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.#assertAccountOwnership(configuredAccount); this.#accountIndex = this.#configuredAccountIndex; return this.#accountIndex; } @@ -1707,56 +2429,264 @@ export class LighterProvider implements PerpsProvider { account.index < min.index ? account : min, ); this.#assertStandardAccount(master.accountType); + this.#assertAccountOwnership(master); 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. + * Verify venue identity before caching it or recording recovery authority. * - * @param accountType - Venue account type code (0 = Standard). + * @param account - Venue account returned for the bound wallet. */ - 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) { + readonly #assertAccountOwnership = ( + account: LighterAccountSummary | undefined, + ): void => { + if ( + account === undefined || + !Number.isSafeInteger(account.index) || + account.index < 0 + ) { throw new Error( - `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} account type could not be verified (account not found); refusing to assume a Standard account`, + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} account index is invalid`, ); } - if (accountType !== 0) { + if ( + this.#configuredAccountIndex !== undefined && + account.index !== this.#configuredAccountIndex + ) { throw new Error( - `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} Premium accounts are not supported yet: their fee semantics are unverified and history would be financially incorrect`, + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} account index does not match the configured account`, + ); + } + if (account.l1Address.toLowerCase() !== this.#boundAddress) { + throw new Error( + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} account is not owned by the selected wallet address`, ); } }; /** - * 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. + * Local account identities recorded before any venue mutation. * - * @param error - Caught error. - * @returns True for capability-gate errors. + * @returns Storage key scoped to the selected wallet and network. */ - readonly #isUnsupportedCapabilityError = (error: unknown): boolean => - String(error).includes(LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX); + readonly #recoveryAccountsKey = (): string => + `lighterRecoveryAccounts:${this.#isTestnet ? 'testnet' : 'mainnet'}:${this.#boundAddress}`; - readonly #isDataIntegrityError = (error: unknown): boolean => - String(error).includes(LIGHTER_DATA_INTEGRITY_PREFIX); + /** + * @param key - Captured wallet/network storage key, or the bound session key. + * @returns Wallet-scoped previously verified venue accounts. + */ + readonly #readRememberedRecoveryAccounts = async ( + key = this.#recoveryAccountsKey(), + ): Promise => { + let raw: string | null; + try { + raw = await this.#deps.diskCache.getItem(key); + } catch (error) { + throw new Error( + `Lighter recovery account index read failed: ${ensureError(error, 'LighterProvider.#readRememberedRecoveryAccounts').message}`, + ); + } + if (raw === null) { + return []; + } + const parsed: unknown = JSON.parse(raw); + if ( + !Array.isArray(parsed) || + parsed.length > LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT || + !parsed.every( + (index) => + typeof index === 'number' && + Number.isSafeInteger(index) && + index >= 0, + ) || + new Set(parsed).size !== parsed.length + ) { + throw new Error('Lighter recovery account index is corrupt'); + } + return parsed as number[]; + }; /** - * Whether a read error represents an explicit non-empty state. + * Preserve discovery of nonce-only obligations if the venue later reports absence. * - * @param error - Caught read error. - * @returns Whether the error must surface to the caller. + * @param accountIndex - Account already verified for this wallet. + * @param generation - Issuing session. */ - readonly #mustSurfaceReadError = (error: unknown): boolean => - this.#isUnsupportedCapabilityError(error) || - this.#isDataIntegrityError(error); + readonly #rememberRecoveryAccount = async ( + accountIndex: number, + generation: number, + ): Promise => { + const key = this.#recoveryAccountsKey(); + await withStorageMutex(key, async () => { + this.#assertSession(generation); + const accounts = await this.#readRememberedRecoveryAccounts(); + this.#assertSession(generation); + if (accounts.includes(accountIndex)) { + return; + } + if (accounts.length >= LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT) { + throw new Error( + 'Lighter recovery account index is full; refusing to hide a future obligation', + ); + } + await this.#deps.diskCache.setItem( + key, + JSON.stringify([...accounts, accountIndex]), + ); + this.#assertSession(generation); + }); + }; + + /** + * Resolve inventory independently of fee/trading capability. Expected absence + * and known Premium accounts retain local obligations without venue mutation. + * Ownership, malformed metadata, transport and storage failures still reject. + * + * @param generation - Issuing wallet session. + * @returns Inventory accounts and whether current venue reconciliation is supported. + */ + readonly #resolveRecoveryInventory = async ( + generation: number, + ): Promise<{ + accounts: number[]; + canReconcile: boolean; + }> => { + const absent = async (): Promise<{ + accounts: number[]; + canReconcile: false; + }> => { + const [remembered, manual, journals] = await Promise.all([ + this.#readRememberedRecoveryAccounts(), + this.#readTpslManualIndex(), + this.#readTpslJournalIndex(), + ]); + this.#assertSession(generation); + const prior = new Set(remembered); + for (const key of [...manual, ...journals]) { + if (!key.startsWith(`${this.#boundAddress}:`)) { + continue; + } + const account = key.split(':')[1]; + const index = Number(account); + if ( + !Number.isSafeInteger(index) || + index < 0 || + String(index) !== account + ) { + throw new Error( + 'Lighter recovery index contains an invalid account identity', + ); + } + prior.add(index); + } + return { + accounts: [...prior].filter( + (index) => + this.#configuredAccountIndex === undefined || + index === this.#configuredAccountIndex, + ), + canReconcile: false, + }; + }; + if (this.#accountIndex !== null) { + return { accounts: [this.#accountIndex], canReconcile: true }; + } + let account: LighterAccountSummary | undefined; + try { + if (this.#configuredAccountIndex === undefined) { + const response = await this.#clientService.getAccountsByL1Address( + this.#walletService.getUserAddress(), + ); + account = response.subAccounts.reduce< + LighterAccountSummary | undefined + >( + (selected, next) => + selected === undefined || next.index < selected.index + ? next + : selected, + undefined, + ); + } else { + const response = await this.#clientService.getAccountByIndex( + this.#configuredAccountIndex, + ); + account = response.accounts[0]; + } + } catch (error) { + this.#assertSession(generation); + if (error instanceof LighterApiError && error.code === 21100) { + return absent(); + } + throw error; + } + this.#assertSession(generation); + if (account === undefined) { + return absent(); + } + this.#assertAccountOwnership(account); + if (account.accountType !== 0 && account.accountType !== 1) { + throw new Error( + `${LIGHTER_UNSUPPORTED_CAPABILITY_PREFIX} recovery account type could not be verified`, + ); + } + if (account.accountType === 0) { + this.#accountIndex = account.index; + } + return { + accounts: [account.index], + canReconcile: account.accountType === 0, + }; + }; + + /** + * 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); + + /** + * Whether a read error represents an explicit non-empty state. + * + * @param error - Caught read error. + * @returns Whether the error must surface to the caller. + */ + readonly #mustSurfaceReadError = (error: unknown): boolean => + this.#isUnsupportedCapabilityError(error) || + this.#isDataIntegrityError(error); /** * TP/SL settlement expectations that timed out before becoming visible @@ -1797,28 +2727,34 @@ export class LighterProvider implements PerpsProvider { * whose outcome is unknown, and a proven never-landed dispatch can * release its nonce for the venue to consume. * + * @param apiKeyIndex - Key slot captured for this operation. * @param accountIndex - Venue account index. * @returns The disk-cache key. */ - readonly #nonceLedgerKey = (accountIndex: number): string => - `lighterNonceLedger:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}:${this.#apiKeyIndex}`; + readonly #nonceLedgerKey = ( + accountIndex: number, + apiKeyIndex = this.#apiKeyIndex, + ): string => + `lighterNonceLedger:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}:${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 apiKeyIndex - Key slot captured for this operation. * @param accountIndex - Venue account index. * @returns The ledger document (consumed-nonce watermark + unresolved * dispatch entries). */ readonly #readNonceLedger = async ( accountIndex: number, + apiKeyIndex = this.#apiKeyIndex, ): Promise => { let raw: string | null; try { raw = await this.#deps.diskCache.getItem( - this.#nonceLedgerKey(accountIndex), + this.#nonceLedgerKey(accountIndex, apiKeyIndex), ); } catch (error) { throw new Error( @@ -1912,6 +2848,7 @@ export class LighterProvider implements PerpsProvider { kind?: number; intent?: string; owner?: string | null; + onDispatch?: () => void; }[] ).map((entry) => ({ ...entry, @@ -1934,6 +2871,7 @@ export class LighterProvider implements PerpsProvider { /** * Persist the dispatch ledger document. * + * @param apiKeyIndex - Key slot captured for this operation. * @param accountIndex - Venue account index. * @param doc - The ledger document. * @param doc.consumedFloor - Highest proven-consumed nonce + 1. @@ -1942,9 +2880,10 @@ export class LighterProvider implements PerpsProvider { readonly #writeNonceLedger = async ( accountIndex: number, doc: LighterNonceLedgerDoc, + apiKeyIndex = this.#apiKeyIndex, ): Promise => { await this.#deps.diskCache.setItem( - this.#nonceLedgerKey(accountIndex), + this.#nonceLedgerKey(accountIndex, apiKeyIndex), JSON.stringify({ version: 4, ...doc }), ); }; @@ -1969,6 +2908,7 @@ export class LighterProvider implements PerpsProvider { * dispatch entry. Lock order is always venueWrite → bridge → ledger * (the ack path takes only the ledger mutex), so no cycle exists. * + * @param apiKeyIndex - Key slot captured for this operation. * @param accountIndex - Venue account index. * @param operation - The ledger RMW critical section. * @returns The operation's result. @@ -1976,8 +2916,12 @@ export class LighterProvider implements PerpsProvider { readonly #withLedgerLock = async ( accountIndex: number, operation: () => Promise, + apiKeyIndex = this.#apiKeyIndex, ): Promise => - await withProcessMutex(this.#nonceLedgerKey(accountIndex), operation); + await withProcessMutex( + this.#nonceLedgerKey(accountIndex, apiKeyIndex), + operation, + ); /** * Append a recovered dispatch without ever sacrificing a blocking @@ -2031,6 +2975,7 @@ export class LighterProvider implements PerpsProvider { * are consumed without quarantine in both cases (their machine * reconciles the intent by exact hash). * + * @param apiKeyIndex - Key slot captured for this operation. * @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. @@ -2040,30 +2985,36 @@ export class LighterProvider implements PerpsProvider { accountIndex: number, entry: LighterNonceLedgerDoc['entries'][number], fenceFailed: boolean, + apiKeyIndex = this.#apiKeyIndex, ): 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'}`; - this.#appendRecoveredDispatch(doc, { - recoveryId, - kind: entry.kind, - intent: entry.intent, - txHash: entry.txHash, - outcome: 'succeeded', - evidence: 'post-dispatch-session-cancelled', - }); - } - await this.#writeNonceLedger(accountIndex, doc); - }); + await this.#withLedgerLock( + accountIndex, + async () => { + const doc = await this.#readNonceLedger(accountIndex, apiKeyIndex); + 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'}`; + this.#appendRecoveredDispatch(doc, { + recoveryId, + kind: entry.kind, + intent: entry.intent, + txHash: entry.txHash, + outcome: 'succeeded', + evidence: 'post-dispatch-session-cancelled', + }); + } + await this.#writeNonceLedger(accountIndex, doc, apiKeyIndex); + }, + apiKeyIndex, + ); /** * Resolve every unresolved dispatch before a write section may issue @@ -2075,31 +3026,51 @@ export class LighterProvider implements PerpsProvider { * blocking until the venue advances. Ambiguity blocks the write. * Runs under the account+slot ledger lock. * + * @param apiKeyIndex - Key slot captured for this operation. * @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), + readonly #resolveNonceLedger = async ( + accountIndex: number, + apiKeyIndex = this.#apiKeyIndex, + ): Promise => + await this.#withLedgerLock( + accountIndex, + async () => this.#resolveNonceLedgerLocked(accountIndex, apiKeyIndex), + apiKeyIndex, ); /** + * @param apiKeyIndex - Key slot captured for this operation. * @param accountIndex - Venue account index. + * @param reconciliation - Non-financial pass that retains owned entries and + * reports pending/quarantined outcomes instead of granting write permission. * @returns Resolves when the pass completes. */ readonly #resolveNonceLedgerLocked = async ( accountIndex: number, + apiKeyIndex = this.#apiKeyIndex, + reconciliation?: { generation: 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 generation = reconciliation?.generation ?? this.#sessionGeneration; + this.#assertSession(generation); + const doc = await this.#readNonceLedger(accountIndex, apiKeyIndex); + this.#assertSession(generation); + const reservationKey = `${accountIndex}:${apiKeyIndex}`; + const releasedNonces: number[] = []; + const commitReservations = (): void => { + this.#assertSession(generation); const floor = this.#nonceReservations.get(reservationKey) ?? 0; this.#nonceReservations.set( reservationKey, Math.max(floor, doc.consumedFloor), ); - } + for (const nonce of releasedNonces) { + if (nonce >= doc.consumedFloor) { + this.#releaseNonceReservation(accountIndex, nonce, apiKeyIndex); + } + } + }; // 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 @@ -2118,8 +3089,13 @@ export class LighterProvider implements PerpsProvider { ); } }; - throwIfQuarantined(); - if (doc.entries.length === 0) { + if (!reconciliation) { + throwIfQuarantined(); + } + if (!doc.entries.some((entry) => !reconciliation || entry.owner === null)) { + if (!reconciliation) { + commitReservations(); + } return; } const quarantine = ( @@ -2142,21 +3118,132 @@ export class LighterProvider implements PerpsProvider { evidence, }); }; + // Persist attached ownership settlement before retiring its nonce evidence. + const settleAttached = async ( + entry: LighterNonceLedgerDoc['entries'][number], + proof: NonNullable, + ): Promise => { + if (!entry.intent.startsWith('placeAttached:')) { + return true; + } + const key = this.#attachedKey(accountIndex); + const groups = await this.#readAttachedGroups(key); + this.#assertSession(generation); + const group = groups.find( + (candidate) => `placeAttached:${candidate.groupId}` === entry.intent, + ); + if ( + !group || + group.accountIndex !== accountIndex || + group.apiKeyIndex !== apiKeyIndex || + group.nonce !== entry.nonce || + group.txHash === null || + group.txHash !== entry.txHash || + group.expiresAt !== entry.expiresAt || + group.venueIds.some((id) => id !== null) || + (proof === 'nonce-consumed' && + (entry.expiresAt === null || + Date.now() <= entry.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS)) || + (group.submission !== 'unknown' && + !(group.submission === 'canceled' && group.nonAcceptance)) + ) { + return false; + } + group.nonAcceptance = proof; + await this.#writeAttachedGroup(key, group, generation); + return true; + }; + const settleScale = async ( + entry: LighterNonceLedgerDoc['entries'][number], + proof: NonNullable, + ): Promise => { + if (!entry.intent.startsWith('placeScale:')) { + return true; + } + if (entry.kind !== LIGHTER_TX_TYPE_CREATE_ORDER || entry.owner !== null) { + return false; + } + const key = this.#scaleKey(accountIndex); + const groups = await this.#readScaleGroups(key, accountIndex); + this.#assertSession(generation); + const group = groups.find((candidate) => + candidate.rungs.some( + (rung) => + entry.intent === + `placeScale:${candidate.groupId}:${rung.clientOrderId}`, + ), + ); + const rung = group?.rungs.find( + (candidate) => + entry.intent === + `placeScale:${group.groupId}:${candidate.clientOrderId}`, + ); + // Transport is ordered after the durable unknown transition. A prepared + // rung therefore proves that this intent never reached transport, even + // if the process stopped after appending its generic nonce ledger entry. + if ( + group && + rung && + group.accountIndex === accountIndex && + group.apiKeyIndex === apiKeyIndex && + rung.state === 'prepared' && + rung.orderId === undefined && + ((rung.nonce === null && + rung.txHash === null && + rung.expiresAt === null) || + (rung.nonce === entry.nonce && + rung.txHash === entry.txHash && + rung.expiresAt === entry.expiresAt)) + ) { + return true; + } + if ( + !group || + !rung || + group.accountIndex !== accountIndex || + group.apiKeyIndex !== apiKeyIndex || + rung.nonce !== entry.nonce || + rung.txHash === null || + rung.txHash !== entry.txHash || + rung.expiresAt !== entry.expiresAt || + rung.orderId !== undefined || + (rung.state !== 'unknown' && + rung.state !== 'submitted' && + !(rung.state === 'rejected' && rung.nonAcceptance !== undefined)) || + (proof === 'nonce-consumed' && + (entry.expiresAt === null || + Date.now() <= entry.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS)) + ) { + return false; + } + if (rung.state !== 'rejected') { + rung.state = 'unknown'; + } + rung.nonAcceptance = proof; + group.placementStopped = true; + await this.#writeScaleGroup(key, group, generation); + return true; + }; const nonceResponse = await this.#clientService.getNextNonce( accountIndex, - this.#apiKeyIndex, + apiKeyIndex, ); + this.#assertSession(generation); + if (!Number.isSafeInteger(nonceResponse.nonce) || nonceResponse.nonce < 0) { + throw new Error( + 'Lighter nonce reconciliation received an invalid venue nonce', + ); + } const remaining: typeof doc.entries = []; for (const entry of doc.entries) { + if (reconciliation && entry.owner !== null) { + remaining.push(entry); + continue; + } 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; } @@ -2165,18 +3252,20 @@ export class LighterProvider implements PerpsProvider { try { lookedUp = await this.#clientService.getTx(entry.txHash); } catch { + this.#assertSession(generation); // Lookup failure is AMBIGUITY, never evidence either way: the // entry stays and the write remains blocked. remaining.push(entry); continue; } + this.#assertSession(generation); 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.apiKeyIndex === apiKeyIndex && lookedUp.nonce === entry.nonce && typeof lookedUp.status === 'number'; if (matchesIdentity) { @@ -2194,12 +3283,15 @@ export class LighterProvider implements PerpsProvider { remaining.push(entry); continue; } + if ( + transactionOutcome === 'failed' && + (!(await settleAttached(entry, 'failed')) || + !(await settleScale(entry, 'failed'))) + ) { + remaining.push(entry); + continue; + } 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 @@ -2223,17 +3315,29 @@ export class LighterProvider implements PerpsProvider { remaining.push(entry); continue; } + if ( + entry.intent.startsWith('updateMargin:') || + entry.intent.startsWith('updateMarginMode:') + ) { + // Additive collateral and mode changes require exact terminal proof. + // An accepted transaction can disappear from an indexing endpoint; + // nonce advance or elapsed expiry alone must not authorize a repeat. + 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. + if ( + !(await settleAttached(entry, 'nonce-consumed')) || + !(await settleScale(entry, 'nonce-consumed')) + ) { + remaining.push(entry); + continue; + } 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 ( @@ -2243,20 +3347,35 @@ export class LighterProvider implements PerpsProvider { // 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); + if ( + !(await settleAttached(entry, 'expired')) || + !(await settleScale(entry, 'expired')) + ) { + remaining.push(entry); + continue; } + releasedNonces.push(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, - }); + this.#assertSession(generation); + await this.#writeNonceLedger( + accountIndex, + { + consumedFloor: doc.consumedFloor, + entries: remaining, + recovered: doc.recovered, + }, + apiKeyIndex, + ); + this.#assertSession(generation); + commitReservations(); + if (reconciliation) { + return; + } 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', @@ -2273,38 +3392,87 @@ export class LighterProvider implements PerpsProvider { * 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. + * current-key entry resolves after a successful explicit TP/SL update. + * Selected obligations can be resolved through an explicitly linked successor. + * Previous-key transactions settle read-only using their original identity; + * an explicit current-key update clears warnings after recorded orders settle. * * @returns Parked manual-recovery entries. */ async getPendingManualRecoveries(): Promise< - { - symbol: string; - settlementKey: string; - recordedAt: number; - reason: string; - priorIntent: 'replace' | 'remove'; - survivingOrderIds: string[]; - actionNeeded: string; - }[] + (PerpsPendingManualRecovery & { providerId?: 'lighter' })[] > { 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'; + const generation = this.#sessionGeneration; + await this.#signerReadyPromise?.catch(() => undefined); + this.#assertSession(generation); + const inventory = await this.#resolveRecoveryInventory(generation); + const rows = await Promise.all( + inventory.accounts.map(async (accountIndex) => + this.#listPendingManualRecoveries(accountIndex, generation), + ), + ); + this.#assertSession(generation); + return rows.flat(); + } + + /** + * @param accountIndex - Verified current or durably remembered account. + * @param generation - Issuing wallet session. + * @returns Local manual obligations scoped to that account. + */ + readonly #listPendingManualRecoveries = async ( + accountIndex: number, + generation: number, + ): ReturnType => { + // Protection belongs to the wallet and venue account, even after its + // trading key migrates. Network storage remains separately scoped. + const identityPrefix = `${this.#boundAddress ?? 'unbound'}:${accountIndex}:`; + const currentSlotPrefix = + this.#readyApiKeyIndex === null + ? null + : `${identityPrefix}${this.#readyApiKeyIndex}:`; + const pending: (PerpsPendingManualRecovery & { providerId?: 'lighter' })[] = + []; + const describePartial = ( + intent: PartialTpslIntent | undefined, + ): PerpsPendingManualRecovery['partialIntent'] => + intent + ? { + version: intent.version, + positionSide: intent.positionSign === 1 ? 'long' : 'short', + linkage: intent.linkage, + legs: intent.orders.map((order) => ({ + type: + order[5] === LIGHTER_ORDER_TYPE_TAKE_PROFIT + ? 'take-profit' + : 'stop-loss', + size: String( + fromLighterInteger(Number(order[2]), intent.sizeDecimals), + ), + clientOrderId: String(order[1]), + })), + } + : undefined; + const partialAction = + 'Review the current position and resolve this exact recovery ID with a fresh explicit protection intent; saved partial quantities will not be replayed'; + const actionNeeded = ( + settlementKey: string, + intent: 'replace' | 'remove', + unsettled = false, + ): string => { + if (currentSlotPrefix === null) { + return 'Initialize the wallet trading key and review the position and recorded TP/SL orders. Select the obligation to reconcile its original submissions before requesting new protection'; + } + if (settlementKey.startsWith(currentSlotPrefix)) { + return intent === 'remove' + ? 'Review surviving orders and submit a new explicit TP/SL removal if you still want to remove protection' + : 'Review the position and submit a new explicit TP/SL update for this symbol to re-establish protection'; + } + return unsettled + ? 'Recorded TP/SL transactions are still settling under a previous trading key. Retry after the venue shows their outcome' + : 'Review surviving orders with the current trading key and submit an explicit TP/SL update or removal. This warning clears once its recorded transactions and orders are settled'; + }; // Storage errors PROPAGATE — a corrupt index degrading to "nothing // pending" would hide a naked position. const manualIndex = await this.#readTpslManualIndex(); @@ -2314,19 +3482,33 @@ export class LighterProvider implements PerpsProvider { } const doc = await this.#loadTpslManualRecovery(settlementKey); if (doc) { + const unsettled = await this.#loadTpslJournal(settlementKey); + this.#assertSession(generation); pending.push({ + providerId: 'lighter', + walletAddress: this.#boundAddress ?? undefined, + network: this.#isTestnet ? 'testnet' : 'mainnet', symbol: doc.symbol, + recoveryId: this.#protectionRecoveryId( + settlementKey, + doc.operationId, + ), settlementKey, recordedAt: doc.recordedAt, reason: doc.reason, priorIntent: doc.priorIntent, survivingOrderIds: doc.survivingOrderIds, - actionNeeded, + ...(doc.partialIntent + ? { partialIntent: describePartial(doc.partialIntent) } + : {}), + actionNeeded: doc.partialIntent + ? partialAction + : actionNeeded(settlementKey, doc.priorIntent, unsettled !== null), }); } } - // Legacy: journals parked 'manual' in the journal slot by an earlier - // version (migrated to the doc on the next settle pass). + // Manual journals, including guarded removals, remain in their slot + // until reconciliation can move them into the durable warning document. const journalIndex = await this.#readTpslJournalIndex(); for (const settlementKey of journalIndex) { if ( @@ -2336,3234 +3518,9950 @@ export class LighterProvider implements PerpsProvider { continue; } const journal = await this.#loadTpslJournal(settlementKey); - if (journal?.phase === 'manual') { + if ( + journal?.sourceRecoverySettlementKey && + journal.sourceRecoveryOperationId && + pending.some( + (entry) => + entry.recoveryId === + this.#protectionRecoveryId( + journal.sourceRecoverySettlementKey as string, + journal.sourceRecoveryOperationId as string, + ), + ) + ) { + continue; + } + if ( + journal && + (journal.partialIntent !== undefined || + journal.phase === 'manual' || + currentSlotPrefix === null || + !settlementKey.startsWith(currentSlotPrefix)) + ) { + let reason = + 'An unfinished TP/SL update from a previous trading key requires reconciliation'; + if (journal.phase === 'manual') { + reason = + journal.intent === 'remove' + ? TPSL_GUARDED_REMOVAL_REVIEW_REASON + : 'TP/SL protection could not be safely re-established automatically (parked by an earlier session)'; + } else if (currentSlotPrefix === null) { + reason = 'An unfinished TP/SL update requires startup reconciliation'; + } else if ( + journal.partialIntent && + settlementKey.startsWith(currentSlotPrefix) + ) { + reason = + 'A partial TP/SL update on the current trading key is pending; it may still be running'; + } pending.push({ - symbol: settlementKey.split(':').at(-1) ?? settlementKey, + providerId: 'lighter', + walletAddress: this.#boundAddress ?? undefined, + network: this.#isTestnet ? 'testnet' : 'mainnet', + symbol: settlementKey.split(':').slice(3).join(':'), + recoveryId: this.#protectionRecoveryId( + settlementKey, + journal.operationId, + ), settlementKey, recordedAt: journal.recordedAt, - reason: - 'TP/SL protection could not be safely re-established automatically (parked by an earlier session)', + reason, priorIntent: journal.intent, survivingOrderIds: [], - actionNeeded, + ...(journal.partialIntent + ? { partialIntent: describePartial(journal.partialIntent) } + : {}), + actionNeeded: journal.partialIntent + ? partialAction + : actionNeeded(settlementKey, journal.intent, true), }); } } + this.#assertSession(generation); 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. + * Read local durable outcomes across the bounded trading-slot range without + * changing ledgers or registering/signing with previous keys. Corruption + * fails closed instead of presenting an incomplete account as empty. * - * @returns The pending recovered-dispatch outcomes. + * @param accountIndex - Captured venue account. + * @param generation - Wallet session owning the read. + * @returns Slot-labelled local ledger documents. */ - async getRecoveredDispatches(): Promise { - this.#ensureSessionBinding(); - const accountIndex = await this.#ensureAccountIndex(); - const doc = await this.#readNonceLedger(accountIndex); - return doc.recovered.map((outcome) => ({ ...outcome })); - } + readonly #readAccountRecoveryLedgers = async ( + accountIndex: number, + generation: number, + ): Promise<{ apiKeyIndex: number; doc: LighterNonceLedgerDoc }[]> => { + const ledgers: { apiKeyIndex: number; doc: LighterNonceLedgerDoc }[] = []; + for ( + let apiKeyIndex = LIGHTER_MIN_TRADING_API_KEY_INDEX; + apiKeyIndex <= LIGHTER_MAX_TRADING_API_KEY_INDEX; + apiKeyIndex += 1 + ) { + this.#assertSession(generation); + const doc = await this.#readNonceLedger(accountIndex, apiKeyIndex); + this.#assertSession(generation); + if (doc.recovered.length > 0 || doc.entries.length > 0) { + ledgers.push({ apiKeyIndex, doc }); + } + } + return ledgers; + }; /** - * 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. + * Read-only account-wide view of durable recovered outcomes, including keys + * skipped during discovery. Waits for an in-flight setup but remains usable + * after setup failure and before initialization. Opaque IDs bind the selected + * wallet, network, account, key slot and original ledger identity. No signing, + * nonce reconciliation, quarantine clearing or financial retry occurs. + * Unresolved raw entries are reported separately as unknown with + * acknowledgeable:false, including current-session in-flight submissions. + * Local-only absent or Premium inventories also refuse acknowledgment. + * Listing is read-only and starts no background reconciliation. A later + * fenced financial action re-checks authoritative state before any dispatch. * - * @param recoveryId - Stable id from {@link getRecoveredDispatches}. + * @returns Pending outcomes labelled with their original trading-key slot. */ - async acknowledgeRecoveredDispatch(recoveryId: string): Promise { + async getRecoveredDispatches(): Promise< + (PerpsRecoveredDispatch & { apiKeyIndex: number })[] + > { 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, - }); - }); + await this.#signerReadyPromise?.catch(() => undefined); + this.#assertSession(generation); + const inventory = await this.#resolveRecoveryInventory(generation); + const rows = await Promise.all( + inventory.accounts.map(async (accountIndex) => + this.#listRecoveredDispatches( + accountIndex, + generation, + inventory.canReconcile, + ), + ), + ); + this.#assertSession(generation); + return rows.flat(); } /** - * 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. + * Format a fenced local view for the already captured account. * - * @param accountIndex - Venue account index. - * @param nonce - The proven-unconsumed nonce. + * @param accountIndex - Captured venue account. + * @param generation - Captured wallet session. + * @param canAcknowledge - Whether a supported current account was verified. + * @returns Scoped pending and recovered dispatches. */ - readonly #releaseNonceReservationIfUnconsumed = async ( + readonly #listRecoveredDispatches = 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); + generation: number, + canAcknowledge: boolean, + ): Promise<(PerpsRecoveredDispatch & { apiKeyIndex: number })[]> => { + const ledgers = await this.#readAccountRecoveryLedgers( + accountIndex, + generation, + ); + this.#assertSession(generation); + return ledgers.flatMap(({ apiKeyIndex, doc }) => [ + ...doc.recovered.map((outcome) => ({ + ...outcome, + ...(canAcknowledge ? {} : { acknowledgeable: false }), + providerId: 'lighter' as const, + walletAddress: this.#boundAddress ?? undefined, + network: this.#isTestnet ? 'testnet' : 'mainnet', + apiKeyIndex, + recoveryId: `lighter:${JSON.stringify([this.#isTestnet ? 'testnet' : 'mainnet', this.#boundAddress, accountIndex, apiKeyIndex, outcome.recoveryId])}`, + })), + ...doc.entries.map((entry) => ({ + providerId: 'lighter' as const, + walletAddress: this.#boundAddress ?? undefined, + network: this.#isTestnet ? 'testnet' : 'mainnet', + recoveryId: `lighter-pending:${JSON.stringify([this.#isTestnet ? 'testnet' : 'mainnet', this.#boundAddress, accountIndex, apiKeyIndex, entry.nonce, entry.txHash])}`, + apiKeyIndex, + acknowledgeable: false, + kind: entry.kind, + intent: entry.intent, + txHash: entry.txHash, + outcome: 'unknown' as const, + evidence: 'unresolved-dispatch', + })), + ]); }; /** - * 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. + * Reconcile owner-null dispatches using unauthenticated venue nonce/hash reads + * and local persistence. This never initializes a signer, signs, submits, + * acknowledges outcomes or starts TP/SL recovery. Owned entries remain pending. + * Existing quarantine and ambiguous slots do not prevent inspection of others. + * A removed pending row can mean proven absence, not successful execution. + * Replace the caller's view with these newly scoped IDs after every pass. * - * @param settlementKey - address:accountIndex:symbol identity. - * @returns The disk-cache key. + * @returns Current account-wide pending and recovered dispatches. */ - readonly #tpslJournalKey = (settlementKey: string): string => - `lighterTpslJournal:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}`; + async reconcileRecoveredDispatches(): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + this.#assertSession(generation); + const network = this.#isTestnet ? 'testnet' : 'mainnet'; + const inventory = await this.#resolveRecoveryInventory(generation); + if (!inventory.canReconcile) { + const rows = await Promise.all( + inventory.accounts.map(async (index) => + this.#listRecoveredDispatches(index, generation, false), + ), + ); + this.#assertSession(generation); + return rows.flat(); + } + const [accountIndex] = inventory.accounts; + if (accountIndex === undefined) { + throw new Error('Lighter recovery account identity is missing'); + } + this.#assertSession(generation); + const result = await withProcessMutex( + `lighterVenueWrite:${network}:${accountIndex}`, + async () => { + this.#assertSession(generation); + for ( + let apiKeyIndex = LIGHTER_MIN_TRADING_API_KEY_INDEX; + apiKeyIndex <= LIGHTER_MAX_TRADING_API_KEY_INDEX; + apiKeyIndex += 1 + ) { + await this.#withLedgerLock( + accountIndex, + async () => { + this.#assertSession(generation); + await this.#resolveNonceLedgerLocked(accountIndex, apiKeyIndex, { + generation, + }); + this.#assertSession(generation); + }, + apiKeyIndex, + ); + this.#assertSession(generation); + } + const rows = await this.#listRecoveredDispatches( + accountIndex, + generation, + true, + ); + this.#assertSession(generation); + return rows; + }, + ); + this.#assertSession(generation); + return result; + } /** - * 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. + * Bind the opaque source ID to the exact durable operation and venue scope. * - * @param settlementKey - Settlement identity. - * @param operationId - The operation identity. - * @returns The disk-cache key. + * @param settlementKey - Original wallet/account/slot/symbol identity. + * @param operationId - Immutable source operation. + * @returns Public opaque selection reference. */ - readonly #tpslJournalOpKey = ( + readonly #protectionRecoveryId = ( + settlementKey: string, + operationId: string, + ): string => { + const [address, account, slot, ...symbolParts] = settlementKey.split(':'); + const symbol = symbolParts.join(':'); + return `lighter-protection:${JSON.stringify([this.#isTestnet ? 'testnet' : 'mainnet', address, Number(account), Number(slot), symbol, operationId])}`; + }; + + readonly #recoverySuccessorKey = ( settlementKey: string, operationId: string, ): string => - `lighterTpslJournalOp:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}:${operationId}`; + `lighterTpslSuccessor:${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 ( + readonly #loadRecoverySuccessor = 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) { + operationId: string, + ): Promise => { + const raw = await this.#deps.diskCache.getItem( + this.#recoverySuccessorKey(settlementKey, operationId), + ); + if (raw === 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; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Invalid Lighter recovery successor record'); + } + const doc = parsed as Record; + if ( + doc.version !== 1 || + doc.sourceSettlementKey !== settlementKey || + doc.sourceOperationId !== operationId || + typeof doc.successorSettlementKey !== 'string' || + typeof doc.successorOperationId !== 'string' || + !['prepared', 'pending', 'failed', 'settled'].includes( + String(doc.state), + ) || + (doc.retainedReplacementGroups !== undefined && + !isRecoveryGroups(doc.retainedReplacementGroups)) || + !Array.isArray(doc.ownedOrderIds) || + !doc.ownedOrderIds.every((id) => typeof id === 'string') + ) { + throw new Error('Invalid Lighter recovery successor identity'); + } + return parsed as TpslRecoverySuccessor; + }; + + readonly #persistRecoverySuccessor = async ( + doc: TpslRecoverySuccessor, + generation: number, + ): Promise => { + this.#assertSession(generation); + await this.#deps.diskCache.setItem( + this.#recoverySuccessorKey( + doc.sourceSettlementKey, + doc.sourceOperationId, + ), + JSON.stringify(doc), + ); + this.#assertSession(generation); + }; + + readonly #assertRecoverySource = async ( + journal: TpslJournalState, + generation: number, + ): Promise => { + this.#assertSession(generation); + if ( + !journal.sourceRecoveryOperationId || + !journal.sourceRecoverySettlementKey + ) { + return; + } + const source = await this.#loadTpslManualRecovery( + journal.sourceRecoverySettlementKey, + ); + this.#assertSession(generation); + if (source?.operationId !== journal.sourceRecoveryOperationId) { + throw new Error('Lighter recovery source operation changed'); + } + }; + + // Caller holds the venue lock. Terminal persistence is the authority for + // cleanup, never permission to repeat a financial intent. + readonly #finalizeRecoverySuccessor = async ( + doc: TpslRecoverySuccessor, + generation: number, + ): Promise => { + this.#assertSession(generation); + if (doc.state !== 'settled') { + throw new Error('Lighter successor is not settled'); + } + const journal = await this.#loadTpslJournal(doc.successorSettlementKey); + this.#assertSession(generation); + if (journal?.operationId === doc.successorOperationId) { + if ( + journal.sourceRecoveryOperationId !== doc.sourceOperationId || + journal.sourceRecoverySettlementKey !== doc.sourceSettlementKey + ) { + throw new Error('Lighter terminal successor link changed'); } - raw = payloadRaw; + await this.#clearTpslJournal( + doc.successorSettlementKey, + doc.successorOperationId, + ); + this.#assertSession(generation); + } else if (journal === null) { + // Prune a pointer whose payload was already removed at the crash boundary. + await this.#clearTpslJournal(doc.successorSettlementKey, null); + this.#assertSession(generation); } - 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 source = await this.#loadTpslManualRecovery(doc.sourceSettlementKey); + this.#assertSession(generation); + if (source === null || source.operationId === doc.sourceOperationId) { + await this.#clearTpslManualRecovery( + doc.sourceSettlementKey, + doc.sourceOperationId, + generation, ); } - 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; + }; + + readonly #verifyRetainedRecoveryCoverage = async ( + journal: TpslJournalState, + readActive: () => Promise, + readInactive: (ids: number[]) => Promise, + ): Promise => { + const generation = this.#sessionGeneration; + const groups = journal.retainedReplacementGroups; + if (!groups || groups.length === 0) { + return; + } + const visible = await this.#awaitTpslVisibility( + readActive, + readInactive, + { createdClientIds: groups.flat(), cancelledOrderIds: [] }, + { createdGroups: groups }, + ); + if (visible.outcome === 'created-terminal-failed') { + const active = await readActive(); + this.#assertSession(generation); + await this.#finishRecoverySuccessor( + journal, + 'failed', + active, + generation, + ); + const sourceKey = journal.sourceRecoverySettlementKey; + const sourceOperation = journal.sourceRecoveryOperationId; + if (!sourceKey || !sourceOperation) { + throw new Error('Lighter retained successor source link is missing'); } - const attempt = value as Record; + const failed = await this.#loadRecoverySuccessor( + sourceKey, + sourceOperation, + ); + this.#assertSession(generation); 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) + !failed || + failed.successorOperationId !== journal.operationId || + failed.state !== 'failed' ) { - 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; + throw new Error('Lighter retained successor operation changed'); } - 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) + await this.#clearTpslJournal( + failed.successorSettlementKey, + journal.operationId, + active, ); - }; - // 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: [], - }; + this.#assertSession(generation); + throw new Error( + 'Lighter retained successor coverage failed; select a new protection intent', + ); + } + if (visible.outcome !== 'settled') { + throw new Error('Lighter retained successor coverage is not settled'); } + }; + + readonly #finishRecoverySuccessor = async ( + journal: TpslJournalState, + state: 'failed' | 'settled', + active: LighterApiOrder[], + generation: number, + ): Promise => { 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), - ) + journal.sourceRecoveryOperationId === undefined || + journal.sourceRecoverySettlementKey === undefined ) { - 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, - }; - } + return; } - throw new Error( - `Lighter TP/SL journal for ${settlementKey} is malformed; refusing protection changes until it is resolved`, + this.#assertSession(generation); + const doc = await this.#loadRecoverySuccessor( + journal.sourceRecoverySettlementKey, + journal.sourceRecoveryOperationId, ); - }; - - /** - * 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 []; + this.#assertSession(generation); + if (!doc || doc.successorOperationId !== journal.operationId) { + throw new Error('Lighter recovery successor operation changed'); } - 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 + const owned = active.filter( + (row) => + journal.priorTriggers.some( + (prior) => prior.orderId === String(row.orderIndex), + ) || + (journal.retainedReplacementGroups ?? []).some((group) => + group.includes(row.clientOrderIndex), + ) || + journal.attempts.some( + (attempt) => + attempt.kind === 'create' && + attempt.clientIds.includes(row.clientOrderIndex), + ), + ); + if (state === 'settled') { + await this.#updateManagedTpsl( + doc.successorSettlementKey, + journal, + active, + ); + this.#assertSession(generation); + } + await this.#persistRecoverySuccessor( + { + ...doc, + state, + retainedReplacementGroups: + state === 'failed' ? undefined : doc.retainedReplacementGroups, + ownedOrderIds: [ + ...new Set([ + ...doc.ownedOrderIds, + ...owned.map((row) => String(row.orderIndex)), + ]), + ], + }, + generation, + ); + if (state === 'settled') { + await this.#finalizeRecoverySuccessor({ ...doc, state }, generation); } - 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. + * Mint read authentication with an existing registered local key only. + * Caller holds the account venue lock; this helper takes the bridge lease. * - * @returns Settlement keys with pending manual recoveries. + * @param accountIndex - Captured account. + * @param generation - Issuing wallet session. + * @param requiredSlot - Restrict authentication to a selected original slot. + * @returns Authentication token, without granting financial readiness. */ - 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'); + readonly #getRecoveryReadToken = async ( + accountIndex: number, + generation: number, + requiredSlot?: number, + ): Promise<{ token: string; apiKeyIndex: number; publicKey: string }> => { + const address = this.#boundAddress; + const bridge = this.#rawSignerBridge(); + return withProcessMutex( + bridgeMutexKey(this.#rawSignerBridge()), + async () => { + this.#assertSession(generation); + const registrations = + await this.#clientService.getApiKeys(accountIndex); + this.#assertSession(generation); + if ( + !Array.isArray(registrations.apiKeys) || + registrations.apiKeys.some( + (key) => + key.accountIndex !== accountIndex || + !Number.isSafeInteger(key.apiKeyIndex) || + typeof key.publicKey !== 'string', + ) + ) { + throw new Error( + 'Lighter recovery review registration metadata unavailable', + ); + } + const requested = registrations.apiKeys + .filter( + (key) => + key.accountIndex === accountIndex && + Number.isSafeInteger(key.apiKeyIndex) && + key.apiKeyIndex >= LIGHTER_MIN_TRADING_API_KEY_INDEX && + key.apiKeyIndex <= LIGHTER_MAX_TRADING_API_KEY_INDEX, + ) + .map((key) => key.apiKeyIndex); + const discover = + bridge.getRecoverableKeyIndices?.bind(bridge) ?? + bridge.getStoredKeyIndices?.bind(bridge); + const discovered = discover + ? await discover({ + chainId: getLighterChainId(this.#clientService.network), + accountIndex, + apiKeyIndices: requested, + walletAddress: address ?? undefined, + }) + : [this.#preferredApiKeyIndex]; + const local = + requiredSlot === undefined + ? [...new Set([this.#preferredApiKeyIndex, ...discovered])].filter( + (slot) => discovered.includes(slot), + ) + : discovered.filter((slot) => slot === requiredSlot); + this.#assertSession(generation); + if (discover && local.some((slot) => !requested.includes(slot))) { + throw new Error( + 'Lighter recovery review received an unrequested local key', + ); + } + try { + for (const slot of local) { + const matches = registrations.apiKeys.filter( + (key) => + key.apiKeyIndex === slot && key.accountIndex === accountIndex, + ); + if (matches.length !== 1) { + continue; + } + const nonce = await this.#clientService.getNextNonce( + accountIndex, + slot, + ); + this.#assertSession(generation); + if (!Number.isSafeInteger(nonce.nonce) || nonce.nonce < 0) { + throw new Error( + 'Lighter recovery review received invalid nonce metadata', + ); + } + const created = await bridge.createClient({ + chainId: getLighterChainId(this.#clientService.network), + accountIndex, + nonce: nonce.nonce, + apiKeyIndex: slot, + walletAddress: address ?? undefined, + }); + this.#assertSession(generation); + if (created.error || !created.success) { + throw new Error( + created.error ?? 'Lighter recovery review client unavailable', + ); + } + const normalize = (key: string): string => + key.replace(/^0x/u, '').toLowerCase(); + if ( + !created.pk || + normalize(created.pk) !== normalize(matches[0].publicKey) + ) { + continue; + } + const auth = await bridge.execute({ + function: '_createAuthToken', + params: [accountIndex, slot], + }); + this.#assertSession(generation); + if ( + auth.error || + !auth.token || + !Number.isFinite(auth.deadline) || + auth.deadline <= Date.now() / 1000 + ) { + throw new Error( + auth.error ?? 'Lighter recovery review auth unavailable', + ); + } + return { + token: auth.token, + apiKeyIndex: slot, + publicKey: created.pk, + }; + } + throw new LighterRecoveryReadAuthorityError( + 'Lighter recovery review requires a matching locally retained registered key; reconnect without replacing venue keys', + ); + } finally { + bridgeClientOwners.delete(this.#rawSignerBridge()); + } + }, + ); }; /** - * Durably record a manual-recovery warning (doc + index entry). + * Read fresh account positions and authenticated orders without financial + * readiness. Only a locally held, already registered key can mint the read + * token; client creation is local and auth signing cannot submit a transaction. + * No authority is cached and ownership is released before returning. * - * @param doc - The manual-recovery record. + * @returns A provider-scoped authoritative snapshot. */ - readonly #writeTpslManualRecovery = async ( - doc: TpslManualRecovery, - ): Promise => { - await withStorageMutex(this.#tpslManualIndexKey(), async () => { - const index = await this.#readTpslManualIndex(); + async reviewRecoveryVenue(): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + this.#assertSession(generation); + const address = this.#boundAddress; + const network = this.#isTestnet ? 'testnet' : 'mainnet'; + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const result = await withProcessMutex( + `lighterVenueWrite:${network}:${accountIndex}`, + async () => { + this.#assertSession(generation); + const { token } = await this.#getRecoveryReadToken( + accountIndex, + generation, + ); + this.#assertSession(generation); + const accountResponse = + await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generation); + const account = accountResponse.accounts?.[0]; + if ( + !Array.isArray(accountResponse.accounts) || + accountResponse.accounts.length !== 1 || + !account || + account.index !== accountIndex || + account.l1Address?.toLowerCase() !== address || + account.accountType !== 0 || + !Array.isArray(account.positions) + ) { + throw new Error( + 'Lighter recovery review account identity or positions unavailable', + ); + } + const metadata = await this.#clientService.getOrderBookDetails(); + this.#assertSession(generation); + if (!Array.isArray(metadata.orderBookDetails)) { + throw new Error( + 'Lighter recovery review market metadata unavailable', + ); + } + const positions = account.positions + .map((position) => { + const markets = metadata.orderBookDetails.filter( + (market) => + market.marketId === position.marketId && + market.symbol === position.symbol, + ); + if ( + markets.length !== 1 || + parseStrictDecimal(position.position) === null || + parseStrictDecimal(position.avgEntryPrice) === null || + (position.sign !== 1 && position.sign !== -1) + ) { + throw new Error( + 'Lighter recovery review position metadata unavailable', + ); + } + return adaptPositionFromLighter( + position, + deriveLighterMaxLeverage( + markets[0].minInitialMarginFraction, + position.marketId, + ), + ); + }) + .filter((position) => parseStrictDecimal(position.size) !== 0); + const active = await this.#clientService.getActiveOrders( + accountIndex, + token, + ); + this.#assertSession(generation); + if (!Array.isArray(active.orders)) { + throw new Error('Lighter recovery review orders unavailable'); + } + const orders = active.orders.map((order) => { + const markets = metadata.orderBookDetails.filter( + (market) => market.marketId === order.marketIndex, + ); + if ( + markets.length !== 1 || + order.ownerAccountIndex !== accountIndex + ) { + throw new Error( + 'Lighter recovery review order identity or metadata unavailable', + ); + } + return adaptOrderFromLighter(order, markets[0].symbol); + }); + this.#assertSession(generation); + return { + status: 'ready' as const, + providerId: 'lighter' as const, + walletAddress: address ?? '', + network, + accountIndex, + positions, + orders, + reviewedAt: Date.now(), + }; + }, + ); + this.#assertSession(generation); + return result; + } + + /** + * Acknowledge one exact stored outcome after the caller refreshes venue state. + * Scoped IDs from any local slot work without changing the active signer. + * Legacy unscoped IDs are accepted only when unique across this account. + * The slot mutex and session fence preserve concurrent appends, unresolved + * entries and all other outcomes. Unknown outcomes require the same explicit + * acknowledgment; this is never permission to resubmit an ambiguous intent. + * + * @param recoveryId - Opaque stable id from {@link getRecoveredDispatches}. + */ + async acknowledgeRecoveredDispatch(recoveryId: string): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + await this.#signerReadyPromise?.catch(() => undefined); + this.#assertSession(generation); + const unmatched = (): Error => + new Error( + 'No pending recovered Lighter dispatch matches this scoped id; refresh and re-read before acknowledging', + ); + if (recoveryId.startsWith('lighter-pending:')) { + throw new Error( + 'Unresolved Lighter dispatches cannot be acknowledged. Use reconcileRecoveredDispatches to check authoritative state; unresolved dispatches remain blocked', + ); + } + let apiKeyIndex: number; + let ledgerRecoveryId: string; + if (recoveryId.startsWith('lighter:')) { + let scope: unknown; + try { + scope = JSON.parse(recoveryId.slice('lighter:'.length)); + } catch { + throw unmatched(); + } if ( - !index.includes(doc.settlementKey) && - index.length >= LIGHTER_TPSL_MANUAL_RECOVERY_LIMIT + !Array.isArray(scope) || + scope.length !== 5 || + scope[0] !== (this.#isTestnet ? 'testnet' : 'mainnet') || + scope[1] !== this.#boundAddress || + scope[2] !== accountIndex || + typeof scope[3] !== 'number' || + !Number.isSafeInteger(scope[3]) || + scope[3] < LIGHTER_MIN_TRADING_API_KEY_INDEX || + scope[3] > LIGHTER_MAX_TRADING_API_KEY_INDEX || + typeof scope[4] !== 'string' ) { - throw new Error( - 'Lighter TP/SL manual-recovery index is full; refusing to hide a new recovery obligation', - ); + throw unmatched(); } - await this.#deps.diskCache.setItem( - this.#tpslManualKey(doc.settlementKey), - JSON.stringify({ version: 1, ...doc }), + apiKeyIndex = scope[3]; + ledgerRecoveryId = scope[4]; + } else { + const matches = ( + await this.#readAccountRecoveryLedgers(accountIndex, generation) + ).filter(({ doc }) => + doc.recovered.some((outcome) => outcome.recoveryId === recoveryId), ); - if (!index.includes(doc.settlementKey)) { - await this.#deps.diskCache.setItem( - this.#tpslManualIndexKey(), - JSON.stringify([...index, doc.settlementKey]), - ); + if (matches.length !== 1) { + throw unmatched(); } - }); + apiKeyIndex = matches[0].apiKeyIndex; + ledgerRecoveryId = recoveryId; + } + await withProcessMutex( + this.#nonceLedgerKey(accountIndex, apiKeyIndex), + async () => { + this.#ensureSessionBinding(); + this.#assertSession(generation); + const doc = await this.#readNonceLedger(accountIndex, apiKeyIndex); + this.#assertSession(generation); + const matching = doc.recovered.filter( + (outcome) => outcome.recoveryId === ledgerRecoveryId, + ); + if (matching.length !== 1) { + throw unmatched(); + } + await this.#writeNonceLedger( + accountIndex, + { + consumedFloor: doc.consumedFloor, + entries: doc.entries, + recovered: doc.recovered.filter( + (outcome) => outcome.recoveryId !== ledgerRecoveryId, + ), + }, + apiKeyIndex, + ); + }, + ); + } + + /** + * 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 apiKeyIndex - Key slot captured for this operation. + * @param accountIndex - Venue account index. + * @param nonce - The proven-unconsumed nonce. + */ + readonly #releaseNonceReservationIfUnconsumed = async ( + accountIndex: number, + nonce: number, + apiKeyIndex = this.#apiKeyIndex, + ): Promise => { + const doc = await this.#readNonceLedger(accountIndex, apiKeyIndex).catch( + () => null, + ); + if (doc === null || nonce < doc.consumedFloor) { + return; + } + this.#releaseNonceReservation(accountIndex, nonce, apiKeyIndex); }; /** - * Load a manual-recovery record. Corruption THROWS (never null) so a - * parked warning cannot silently vanish. + * 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 - Settlement identity. - * @returns The record, or null when none is parked. + * @param settlementKey - address:accountIndex:symbol identity. + * @returns The disk-cache key. */ - readonly #loadTpslManualRecovery = async ( + readonly #tpslJournalKey = (settlementKey: string): string => + `lighterTpslJournal:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}`; + + /** + * Protection belongs to the wallet/account/market, not the signing key slot. + * A key recovered into another slot must still recognize this device's orders. + * + * @param settlementKey - Address:accountIndex:apiKeyIndex:symbol identity. + * @returns The network-scoped ownership storage key. + */ + readonly #managedTpslKey = (settlementKey: string): string => { + const [address, accountIndex, , ...symbolParts] = settlementKey.split(':'); + const symbol = symbolParts.join(':'); + return `lighterManagedTpsl:${this.#isTestnet ? 'testnet' : 'mainnet'}:${address}:${accountIndex}:${symbol}`; + }; + + /** + * Read ownership strictly. Storage failure or corruption never means unowned. + * + * @param settlementKey - Captured settlement identity. + * @returns Recorded client and venue identities. + */ + readonly #readManagedTpsl = async ( settlementKey: string, - ): Promise => { + ): Promise => { const raw = await this.#deps.diskCache.getItem( - this.#tpslManualKey(settlementKey), + this.#managedTpslKey(settlementKey), ); if (raw === null) { - return null; + return []; } - 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 + const parsed = JSON.parse(raw) as { version?: unknown; orders?: unknown }; + if ( + parsed === null || + parsed.version !== 1 || + !Array.isArray(parsed.orders) || + parsed.orders.length > LIGHTER_TPSL_OWNERSHIP_MAX_ORDERS || + !parsed.orders.every((entry: unknown) => { + if (typeof entry !== 'object' || entry === null) { + return false; + } + const order = entry as Partial; + return ( + typeof order.clientId === 'string' && + /^\d{1,15}$/u.test(order.clientId) && + Number.isSafeInteger(Number(order.clientId)) && + Number(order.clientId) > 0 && + (order.orderId === null || + (typeof order.orderId === 'string' && + /^\d{1,20}$/u.test(order.orderId))) && + (order.orderExpiry === undefined || + (typeof order.orderExpiry === 'number' && + Number.isSafeInteger(order.orderExpiry) && + order.orderExpiry > 0)) + ); + }) + ) { + throw new Error( + 'Invalid Lighter managed TP/SL ownership; refusing protection changes', + ); } - throw new Error( - `Lighter TP/SL manual-recovery record for ${settlementKey} is corrupt; resolve storage before proceeding`, - ); + const orders = parsed.orders as ManagedTpslOrder[]; + if (new Set(orders.map((entry) => entry.clientId)).size !== orders.length) { + throw new Error( + 'Duplicate Lighter managed TP/SL ownership; refusing protection changes', + ); + } + return orders; }; /** - * Clear a manual-recovery record — called ONLY after a successor - * protection intent has authoritatively succeeded. + * Record every journalled creation before dispatch. At proven settlement, + * prune only proven cancelled venue IDs. A lagging book never erases a create. + * Unrelated or uncertain IDs from another slot's operation are retained. * - * @param settlementKey - Settlement identity. + * @param settlementKey - Captured settlement identity. + * @param journal - Operation whose IDs are being updated. + * @param settledActive - Strict active book after authoritative settlement. + * @returns Newly inserted client IDs, for rollback of a failed pre-dispatch write. */ - readonly #clearTpslManualRecovery = async ( + readonly #updateManagedTpsl = async ( settlementKey: string, - ): Promise => { - await withStorageMutex(this.#tpslManualIndexKey(), async () => { - const index = await this.#readTpslManualIndex(); - await this.#deps.diskCache.removeItem(this.#tpslManualKey(settlementKey)); - if (index.includes(settlementKey)) { - await this.#deps.diskCache.setItem( - this.#tpslManualIndexKey(), - JSON.stringify(index.filter((entry) => entry !== settlementKey)), + journal: TpslJournalState, + settledActive?: LighterApiOrder[], + ): Promise => { + const key = this.#managedTpslKey(settlementKey); + return await withStorageMutex(key, async () => { + const orders = new Map( + (await this.#readManagedTpsl(settlementKey)).map((entry) => [ + entry.clientId, + entry, + ]), + ); + const createdOrders = new Map( + journal.attempts.flatMap((attempt) => + attempt.kind === 'create' && + attempt.neverLanded !== true && + getLighterTransactionOutcome(attempt.terminalStatus) !== 'failed' + ? attempt.clientIds.map((id, index): [string, ManagedTpslOrder] => [ + String(id), + { + clientId: String(id), + orderId: null, + ...(attempt.orderExpiries === undefined + ? {} + : { orderExpiry: attempt.orderExpiries[index] }), + }, + ]) + : [], + ), + ); + const inserted: string[] = []; + for (const [clientId, entry] of settledActive ? [] : createdOrders) { + if (!orders.has(clientId)) { + inserted.push(clientId); + orders.set(clientId, entry); + } + } + if (settledActive) { + const cancelledIds = new Set( + journal.attempts.flatMap((attempt) => + attempt.kind === 'cancel' ? [attempt.orderId] : [], + ), + ); + for (const [clientId, entry] of orders) { + const active = settledActive.find( + (row) => String(row.clientOrderIndex) === clientId, + ); + if (active) { + orders.set(clientId, { + ...entry, + orderId: String(active.orderIndex), + }); + } else if ( + entry.orderId !== null && + cancelledIds.has(entry.orderId) + ) { + orders.delete(clientId); + } + } + } + if (orders.size > LIGHTER_TPSL_OWNERSHIP_MAX_ORDERS) { + throw new Error( + 'Lighter managed TP/SL ownership is full; refusing further protection changes', ); } + await this.#deps.diskCache.setItem( + key, + JSON.stringify({ version: 1, orders: [...orders.values()] }), + ); + return inserted; }); }; /** - * 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. + * Drop IDs only with explicit pre-dispatch, terminal-failure or expiry proof. * - * @param settlementKey - Settlement identity. - * @param journal - The journal entry. + * @param settlementKey - Captured settlement identity. + * @param clientIds - Exact IDs proven incapable of creating a live order. */ - readonly #persistTpslJournal = async ( + readonly #discardManagedTpslIds = async ( settlementKey: string, - journal: TpslJournalState, + clientIds: string[], ): 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 (clientIds.length === 0) { + return; } - 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`, + const key = this.#managedTpslKey(settlementKey); + await withStorageMutex(key, async () => { + const orders = await this.#readManagedTpsl(settlementKey); + await this.#deps.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + orders: orders.filter((entry) => !clientIds.includes(entry.clientId)), + }), ); + }); + }; + + /** + * Prune ownership on exact terminal history or signed order expiry plus slack. + * Absence from an active read alone never discards an uncertain creation. + * Bookkeeping reads are bounded and best-effort, unlike journal settlement. + * + * @param settlementKey - Captured settlement identity. + * @param active - Strict active venue book. + * @param readRecentInactive - One-page, captured-account inactive reader. + */ + readonly #pruneManagedTpsl = async ( + settlementKey: string, + active: LighterApiOrder[], + readRecentInactive: () => Promise, + ): Promise => { + const orders = await this.#readManagedTpsl(settlementKey); + const missing = orders.filter( + (entry) => + !active.some((row) => String(row.clientOrderIndex) === entry.clientId), + ); + if (missing.length === 0) { + return; } - // 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 expired = new Map( + missing + .filter( + (entry) => + entry.orderExpiry !== undefined && + Date.now() - entry.orderExpiry > LIGHTER_TX_EXPIRY_SLACK_MS, + ) + .map((entry) => [entry.clientId, entry.orderExpiry]), + ); + const missingIds = new Set( + missing + .filter((entry) => !expired.has(entry.clientId)) + .map((entry) => entry.clientId), + ); + let inactive: LighterApiOrder[] = []; + if (missingIds.size > 0) { + try { + inactive = await readRecentInactive(); + } catch (error) { + this.#deps.debugLogger.log( + '[LighterProvider] TP/SL ownership history unavailable; retaining uncertain IDs', + { error: String(error) }, ); } - }); - 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) { + } + const terminalIds = new Set( + inactive + .filter((row) => { + if (!missingIds.has(String(row.clientOrderIndex))) { + return false; + } + const status = row.status.toLowerCase(); + if (status === 'rejected') { + return true; + } + if (status === 'executed') { + return parseStrictDecimal(row.remainingBaseAmount) === 0; + } try { - currentOperationId = ( - JSON.parse(currentRaw) as { operationId?: unknown } - ).operationId; + // The adapter is the canonical venue status vocabulary, including + // OCO, reduce-only, expiry and other cancellation causes. + const adapted = adaptOrderFromLighter(row, String(row.marketIndex)); + return ( + adapted.status === 'canceled' || + (adapted.status === 'filled' && + parseStrictDecimal(row.remainingBaseAmount) === 0) + ); } catch { - // Corrupt current journal: fail closed below via mismatch. + // Unknown states retain ownership rather than guessing termination. + return false; } - } - 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. + }) + .map((row) => String(row.clientOrderIndex)), + ); + if (terminalIds.size === 0 && expired.size === 0) { + return; + } + const key = this.#managedTpslKey(settlementKey); + await withStorageMutex(key, async () => { + const current = await this.#readManagedTpsl(settlementKey); await this.#deps.diskCache.setItem( - this.#tpslJournalOpKey(settlementKey, journal.operationId), + key, 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, + version: 1, + orders: current.filter( + (entry) => + !terminalIds.has(entry.clientId) && + !( + expired.has(entry.clientId) && + expired.get(entry.clientId) === entry.orderExpiry + ), + ), }), ); - 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. + * 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 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). + * @param operationId - The operation identity. + * @returns The disk-cache key. */ - readonly #clearTpslJournal = async ( + readonly #tpslJournalOpKey = ( 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; - }; + operationId: string, + ): string => + `lighterTpslJournalOp:${this.#isTestnet ? 'testnet' : 'mainnet'}:${settlementKey}:${operationId}`; /** - * 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. + * 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 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. + * @param settlementKey - Settlement identity. + * @returns The validated entry, or null. */ - 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 => { + 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; + sourceRecoveryOperationId?: unknown; + sourceRecoverySettlementKey?: unknown; + retainedReplacementGroups?: unknown; + partialIntent?: 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 && + (attempt.orderExpiries === undefined || + (Array.isArray(attempt.orderExpiries) && + attempt.orderExpiries.length === attempt.clientIds.length && + attempt.orderExpiries.every(isExpiry))) + ); + } + 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 || + parsed.version === 5 || + parsed.version === 6) && + (parsed.version === 5 || parsed.version === 6 + ? isPartialTpslIntent(parsed.partialIntent) && + parsed.partialIntent.version === (parsed.version === 6 ? 2 : 1) && + parsed.intent === 'replace' && + parsed.phase !== 'creating' + : parsed.partialIntent === undefined) && + typeof parsed.recordedAt === 'number' && + Number.isSafeInteger(parsed.recordedAt) && + parsed.recordedAt >= 0 && + // The journal is bound to ONE api-key slot: nonces are per slot. + String(parsed.apiKeyIndex) === settlementKey.split(':').at(-2) && + typeof parsed.apiKeyIndex === 'number' && + Number.isSafeInteger(parsed.apiKeyIndex) && + parsed.apiKeyIndex >= 0 && + (parsed.retainedReplacementGroups === undefined || + isRecoveryGroups(parsed.retainedReplacementGroups)) && + (parsed.sourceRecoverySettlementKey === undefined) === + (parsed.sourceRecoveryOperationId === undefined) && + (parsed.sourceRecoverySettlementKey === undefined || + (typeof parsed.sourceRecoverySettlementKey === 'string' && + parsed.sourceRecoverySettlementKey.length > 0)) && + (parsed.sourceRecoveryOperationId === undefined || + (typeof parsed.sourceRecoveryOperationId === 'string' && + parsed.sourceRecoveryOperationId.length > 0 && + parsed.sourceRecoveryOperationId.length <= 64)) && + 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) && + // Only schema 5 records a validated intent before any cancellation. + // Older schemas require at least one signed attempt. + (parsed.attempts.length >= 1 || + parsed.version === 5 || + parsed.version === 6) && + 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), + ), + ); + const independentIntent = parsed.partialIntent; + const independentAttemptsValid = + !isPartialTpslIntent(independentIntent) || + independentIntent.version !== 2 || + attempts + .filter((attempt) => attempt.kind === 'create') + .every( + (attempt, index) => + attempt.kind === 'create' && + attempt.role === 'replacement' && + attempt.clientIds.length === 1 && + independentIntent.orders[index]?.[1] === attempt.clientIds[0], + ); + if (restoresLinked && independentAttemptsValid) { + return { + attempts: attempts.map((attempt) => + attempt.kind === 'create' + ? { ...attempt, neverLanded: undefined } + : attempt, + ), + partialIntent: isPartialTpslIntent(parsed.partialIntent) + ? parsed.partialIntent + : undefined, + recordedAt: parsed.recordedAt, + operationId: parsed.operationId, + retainedReplacementGroups: isRecoveryGroups( + parsed.retainedReplacementGroups, + ) + ? parsed.retainedReplacementGroups + : undefined, + sourceRecoverySettlementKey: + typeof parsed.sourceRecoverySettlementKey === 'string' + ? parsed.sourceRecoverySettlementKey + : undefined, + sourceRecoveryOperationId: + typeof parsed.sourceRecoveryOperationId === 'string' + ? parsed.sourceRecoveryOperationId + : undefined, + 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 withStorageMutex(this.#tpslManualIndexKey(), async () => { + const index = await this.#readTpslManualIndex(); + if ( + !index.includes(doc.settlementKey) && + index.length >= LIGHTER_TPSL_MANUAL_RECOVERY_LIMIT + ) { + throw new Error( + 'Lighter TP/SL manual-recovery index is full; refusing to hide a new recovery obligation', + ); + } + await this.#deps.diskCache.setItem( + this.#tpslManualKey(doc.settlementKey), + JSON.stringify({ version: 1, ...doc }), + ); + if (!index.includes(doc.settlementKey)) { + await this.#deps.diskCache.setItem( + this.#tpslManualIndexKey(), + JSON.stringify([...index, doc.settlementKey]), + ); + } + }); + }; + + /** + * 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 && + (parsed.partialIntent === undefined || + isPartialTpslIntent(parsed.partialIntent)) && + 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 { + partialIntent: isPartialTpslIntent(parsed.partialIntent) + ? parsed.partialIntent + : undefined, + 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 an obsolete manual-recovery record after an explicit protection + * intent succeeds, including a guarded removal settled during recovery. + * + * @param settlementKey - Settlement identity. + * @param expectedOperationId - Exact source to clear, when explicitly selected. + * @param generation - Issuing wallet session. + * @param requireSettledJournal - Keep previous-slot warnings while a journal survives. + */ + readonly #clearTpslManualRecovery = async ( + settlementKey: string, + expectedOperationId?: string, + generation = this.#sessionGeneration, + requireSettledJournal = false, + ): Promise => { + await withStorageMutex(this.#tpslManualIndexKey(), async () => { + this.#assertSession(generation); + const current = await this.#loadTpslManualRecovery(settlementKey); + this.#assertSession(generation); + if ( + expectedOperationId !== undefined && + current !== null && + current.operationId !== expectedOperationId + ) { + if (requireSettledJournal) { + return; + } + throw new Error( + 'Lighter recovery source operation changed before completion', + ); + } + const index = await this.#readTpslManualIndex(); + this.#assertSession(generation); + if (expectedOperationId !== undefined) { + const latest = await this.#loadTpslManualRecovery(settlementKey); + this.#assertSession(generation); + if (latest && latest.operationId !== expectedOperationId) { + return; + } + } + if (requireSettledJournal) { + const journal = await this.#loadTpslJournal(settlementKey); + this.#assertSession(generation); + if (journal) { + return; + } + } + await this.#deps.diskCache.removeItem(this.#tpslManualKey(settlementKey)); + this.#assertSession(generation); + if (index.includes(settlementKey)) { + await this.#deps.diskCache.setItem( + this.#tpslManualIndexKey(), + JSON.stringify(index.filter((entry) => entry !== settlementKey)), + ); + this.#assertSession(generation); + } + }); + }; + + /** + * Retire other-slot warnings only after an explicit current-key intent + * succeeds. Reads alone never acknowledge surviving protection. + * @param settlementKey - Current wallet/account/slot/symbol identity. + * @param accountIndex - Captured venue account. + * @param symbol - Symbol changed by the successful explicit intent. + * @param generation - Captured wallet session. + * @param readActiveRaw - Strict account-wide active book reader. + */ + readonly #clearSettledPreviousSlotWarnings = async ( + settlementKey: string, + accountIndex: number, + symbol: string, + generation: number, + readActiveRaw: () => Promise, + ): Promise => { + const index = await this.#readTpslManualIndex(); + this.#assertSession(generation); + for (const key of index) { + const [address, account, , ...keySymbol] = key.split(':'); + if ( + key === settlementKey || + address !== this.#boundAddress || + account !== String(accountIndex) || + keySymbol.join(':') !== symbol + ) { + continue; + } + // A manual document can coexist with an unresolved journal after a + // failed cleanup. Preserve that obligation regardless of book absence. + if (await this.#loadTpslJournal(key)) { + this.#assertSession(generation); + continue; + } + this.#assertSession(generation); + const doc = await this.#loadTpslManualRecovery(key); + this.#assertSession(generation); + if (!doc) { + // A document removal may have landed before its index write failed. + // Recheck under the writer mutex so a newly parked warning survives. + await withStorageMutex(this.#tpslManualIndexKey(), async () => { + const currentIndex = await this.#readTpslManualIndex(); + const currentDoc = await this.#loadTpslManualRecovery(key); + const currentJournal = await this.#loadTpslJournal(key); + this.#assertSession(generation); + if (!currentDoc && !currentJournal && currentIndex.includes(key)) { + await this.#deps.diskCache.setItem( + this.#tpslManualIndexKey(), + JSON.stringify(currentIndex.filter((entry) => entry !== key)), + ); + this.#assertSession(generation); + } + }); + continue; + } + if (doc.settlementKey !== key || doc.symbol !== symbol) { + throw new Error( + 'Lighter previous-slot protection warning identity is invalid', + ); + } + // Partial recovery requires its exact explicit successor; an ordinary + // protection update must not retire saved partial intent. + if (doc.partialIntent) { + continue; + } + const ids = [ + ...doc.survivingOrderIds, + ...doc.priorTriggers.map((prior) => prior.orderId), + ]; + if ( + ids.some( + (id) => + typeof id !== 'string' || + !/^[1-9]\d*$/u.test(id) || + !Number.isSafeInteger(Number(id)), + ) + ) { + throw new Error( + 'Lighter previous-slot protection warning order identity is invalid', + ); + } + const readSurvivors = async (): Promise => { + const active = await readActiveRaw(); + this.#assertSession(generation); + if ( + !Array.isArray(active) || + active.some( + (row) => + !row || + row.ownerAccountIndex !== accountIndex || + !Number.isSafeInteger(row.orderIndex) || + row.orderIndex <= 0 || + !Number.isSafeInteger(row.marketIndex) || + row.marketIndex < 0, + ) + ) { + throw new Error( + 'Lighter previous-slot protection review requires a complete account order response', + ); + } + return active.some((row) => ids.includes(String(row.orderIndex))); + }; + if ((await readSurvivors()) || (await readSurvivors())) { + continue; + } + if (await this.#loadTpslJournal(key)) { + this.#assertSession(generation); + continue; + } + this.#assertSession(generation); + await this.#clearTpslManualRecovery( + key, + doc.operationId, + generation, + true, + ); + this.#assertSession(generation); + } + }; + + /** + * 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`, + ); + } + } + const previous = danglingPointer + ? null + : await this.#loadTpslJournal(settlementKey); + if ( + previous && + JSON.stringify(previous.partialIntent) !== + JSON.stringify(journal.partialIntent) + ) { + throw new Error( + 'Cannot change the persisted partial protection intent', + ); + } + const priorCreateIds = new Set( + previous?.attempts.flatMap((attempt) => + attempt.kind === 'create' ? attempt.clientIds.map(String) : [], + ) ?? [], + ); + // Ownership must succeed BEFORE publishing an unknown journal attempt. + // A failed ownership write therefore leaves no never-sent obligation. + const insertedOwnership = await this.#updateManagedTpsl( + settlementKey, + journal, + ); + try { + await this.#deps.diskCache.setItem( + this.#tpslJournalOpKey(settlementKey, journal.operationId), + JSON.stringify({ + version: journal.partialIntent + ? journal.partialIntent.version + 4 + : 4, + partialIntent: journal.partialIntent, + recordedAt: journal.recordedAt, + operationId: journal.operationId, + sourceRecoveryOperationId: journal.sourceRecoveryOperationId, + sourceRecoverySettlementKey: journal.sourceRecoverySettlementKey, + retainedReplacementGroups: journal.retainedReplacementGroups, + createdAt: journal.createdAt, + nextAttemptId: journal.nextAttemptId, + apiKeyIndex: this.#apiKeyIndex, + intent: journal.intent, + phase: journal.phase, + priorGrouping: journal.priorGrouping, + priorTriggers: journal.priorTriggers, + attempts: journal.attempts.map((attempt) => + attempt.kind === 'create' + ? { ...attempt, neverLanded: undefined } + : attempt, + ), + }), + ); + if (!pointerAlreadyOurs) { + 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); + } + // Only brand-new attempts absent from the previous durable journal + // are known never dispatched. Retain all prior committed identities. + try { + await this.#discardManagedTpslIds( + settlementKey, + insertedOwnership.filter((id) => !priorCreateIds.has(id)), + ); + } catch (rollbackError) { + // Keep ownership conservatively if storage also fails during rollback. + // Bounded bookkeeping can reclaim them after their signed order expiry. + this.#deps.debugLogger.log( + '[LighterProvider] pre-dispatch ownership rollback failed; retaining IDs', + { error: String(rollbackError) }, + ); + } + 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. + * @param settledActive - Strict active book after the operation settled. + * @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, + settledActive?: LighterApiOrder[], + ): 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; + } + if (settledActive) { + const journal = await this.#loadTpslJournal(settlementKey); + if (journal) { + await this.#updateManagedTpsl( + settlementKey, + journal, + settledActive, + ); + } + } + 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; + } + if (settledActive) { + const journal = await this.#loadTpslJournal(settlementKey); + if (journal) { + await this.#updateManagedTpsl(settlementKey, journal, settledActive); + } + } + 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(); + const conflictingClients = new Set(); + 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) { + const clientId = String(order.clientOrderIndex); + const previous = terminalCache.get(clientId); + if ( + previous && + (previous.orderIndex !== order.orderIndex || + previous.marketIndex !== order.marketIndex) + ) { + conflictingClients.add(clientId); + } + terminalCache.set(clientId, order); + } + } + }; + const firstPage = await this.#clientService.getInactiveOrders( + accountIndex, + authToken, + 100, + undefined, + marketId, + ); + this.#assertSession(generation); + let rowsRead = firstPage.orders.length; + if (rowsRead > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history exceeded ${LIGHTER_INACTIVE_HISTORY_ROW_LIMIT} rows; TP/SL recovery is still pending`, + ); + } + ingest(firstPage.orders); + if (missing() && !deepTraversalDone) { + let cursor = firstPage.nextCursor; + const seenCursors = new Set(); + let pagesRead = 1; + while (cursor && missing()) { + if (seenCursors.has(cursor)) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history repeated cursor ${cursor} after ${rowsRead} rows; TP/SL recovery is still pending`, + ); + } + seenCursors.add(cursor); + const response = await this.#clientService.getInactiveOrders( + accountIndex, + authToken, + 100, + cursor, + marketId, + ); + this.#assertSession(generation); + pagesRead += 1; + if ( + response.orders.length === 0 && + response.nextCursor !== undefined + ) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history advanced without returning rows; TP/SL recovery is still pending`, + ); + } + if (pagesRead > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history exceeded ${LIGHTER_INACTIVE_HISTORY_ROW_LIMIT} pages; TP/SL recovery is still pending`, + ); + } + rowsRead += response.orders.length; + if (rowsRead > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history exceeded ${LIGHTER_INACTIVE_HISTORY_ROW_LIMIT} rows (${rowsRead} rows returned); TP/SL recovery is still pending`, + ); + } + ingest(response.orders); + cursor = response.nextCursor; + } + deepTraversalDone = !cursor || !missing(); + } + if (targets.some((id) => conflictingClients.has(id))) { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} conflicting inactive-order identity for requested client; TP/SL recovery is still pending`, + ); + } + 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}:`; + let allResolved = true; + for (const settlementKey of index) { + if (!settlementKey.startsWith(prefix)) { + continue; + } + const [slot, ...symbol] = settlementKey + .slice(prefix.length) + .split(':'); + const originalSlot = Number(slot); + const resolved = await this.#recoverTpslSymbol( + symbol.join(':'), + settlementKey, + generation, + accountIndex, + originalSlot, + ).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. + * @param originalSlot - Original journal slot, read-only if different after setup. + * @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, + originalSlot = this.#apiKeyIndex, + ): 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, + readOnlyApiKeyIndex: + originalSlot === this.#apiKeyIndex ? undefined : originalSlot, + 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.readOnlyApiKeyIndex - Original slot for read-only migrated settlement. + * @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; + readOnlyApiKeyIndex?: number; + 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; + beforeDispatch?: () => Promise; + onDispatch?: () => void; + }, + ) => 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.readOnlyApiKeyIndex - Original slot for read-only migrated settlement. + * @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; + readOnlyApiKeyIndex?: number; + 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; + beforeDispatch?: () => Promise; + onDispatch?: () => void; + }, + ) => 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 successor = await this.#loadRecoverySuccessor( + settlementKey, + journalEntry.operationId, + ); + this.#assertSession(context.generation); + if (successor && successor.state !== 'failed') { + return false; + } + if ( + journalEntry.sourceRecoveryOperationId && + journalEntry.sourceRecoverySettlementKey + ) { + const linked = await this.#loadRecoverySuccessor( + journalEntry.sourceRecoverySettlementKey, + journalEntry.sourceRecoveryOperationId, + ); + this.#assertSession(context.generation); + if ( + linked?.state === 'failed' && + linked.successorOperationId === journalEntry.operationId && + linked.successorSettlementKey === settlementKey + ) { + // Failure retirement may have stopped after its durable marker. + // Replay only exact cleanup; never retry the failed financial intent. + await this.#clearTpslJournal(settlementKey, journalEntry.operationId); + this.#assertSession(context.generation); + return false; + } + if ( + linked?.state === 'settled' && + linked.successorOperationId === journalEntry.operationId && + linked.successorSettlementKey === settlementKey + ) { + await this.#finalizeRecoverySuccessor(linked, context.generation); + return true; + } + } + // Check before compaction: an empty original attempt list proves no + // transaction was sent; a compacted list alone would not prove this. + if (journalEntry.partialIntent && journalEntry.attempts.length === 0) { + await this.#finishRecoverySuccessor( + journalEntry, + 'failed', + journalEntry.sourceRecoveryOperationId ? await readActiveRaw() : [], + context.generation, + ); + return await this.#clearTpslJournal( + settlementKey, + journalEntry.operationId, + ); + } + await this.#assertRecoverySource(journalEntry, context.generation); + const reconciled = await this.#reconcilePriorTpsl( + readActiveRaw, + readInactiveFor, + accountIndex, + journalEntry, + context.readOnlyApiKeyIndex, + ); + if (reconciled === 'unresolved') { + return false; + } + // Diagnose retained coverage before issuing any remaining cancellations. + await this.#verifyRetainedRecoveryCoverage( + journalEntry, + readActiveRaw, + readInactiveFor, + ); + await this.#discardManagedTpslIds( + settlementKey, + journalEntry.attempts.flatMap((attempt) => + attempt.kind === 'create' && + (attempt.neverLanded === true || + getLighterTransactionOutcome(attempt.terminalStatus) === 'failed') + ? attempt.clientIds.map(String) + : [], + ), + ); + 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 (context.readOnlyApiKeyIndex !== undefined) { + throw new Error( + 'Read-only original-slot settlement cannot sign a cancellation', + ); + } + if (role === 'stale' && journalEntry.intent === 'replace') { + journalEntry.phase = 'cancelling'; + } + await this.#assertRecoverySource(journalEntry, context.generation); + 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, + beforeDispatch: async () => + this.#assertRecoverySource(journalEntry, context.generation), + }, + ); + }; + // 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 0) landed + // without mutating the books: proven-resolved, compactable. + const landedTerminalFailed = + getLighterTransactionOutcome(attempt.terminalStatus) === 'failed'; + 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. + if (journalEntry.sourceRecoveryOperationId) { + await this.#finishRecoverySuccessor( + journalEntry, + 'failed', + rawActive, + context.generation, + ); + } else { + await this.#writeTpslManualRecovery({ + settlementKey, + symbol, + reason, + partialIntent: journalEntry.partialIntent, + 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, + await readActiveRaw(), + ); + return true; + }; + if ( + journalEntry.partialIntent?.version === 2 && + (anyFailed || + journalEntry.partialIntent.orders.some( + (order) => !replacementIds.includes(order[1]), + )) + ) { + return await parkManual( + 'Independent protection was not fully established; surviving legs require explicit current-position recovery', + ); + } + if (context.readOnlyApiKeyIndex !== undefined) { + const hasPriorSurvivors = journalEntry.priorTriggers.some(priorActive); + const replacementWon = anySuccess || (anyActive && !anyFailed); + const needsPriorCleanup = + hasPriorSurvivors && + (journalEntry.intent === 'remove' || + (journalEntry.phase === 'creating' && + replacementIds.length > 0 && + replacementWon) || + (journalEntry.phase === 'cancelling' && replacementWon)); + const needsRollback = + journalEntry.intent === 'replace' && + journalEntry.phase === 'creating' && + !anySuccess && + anyFailed && + anyActive; + if (needsPriorCleanup || needsRollback) { + return await parkManual( + 'An earlier trading key completed its dispatched attempts but further protection changes require a new explicit request; surviving orders were preserved', + ); + } + } + if (journalEntry.phase === 'manual') { + if ( + journalEntry.intent === 'remove' && + !journalEntry.priorTriggers.some(priorActive) + ) { + // Reconciliation above settled every actual attempt. Confirm the + // removed targets remain absent in a fresh complete book before + // retiring their obligation, without authorizing another cancel. + const settledActive = await readActiveRaw(); + if ( + journalEntry.priorTriggers.some((prior) => + settledActive.some( + (order) => String(order.orderIndex) === prior.orderId, + ), + ) + ) { + return false; + } + // Clear the obsolete warning first: if storage fails, the journal + // survives so a later read/restart can finish this same cleanup. + await this.#clearTpslManualRecovery(settlementKey); + return await this.#clearTpslJournal( + settlementKey, + journalEntry.operationId, + settledActive, + ); + } + // Reconciled manual journals release only their settlement slot. + // Keep surviving protection in the durable warning for explicit action. + return await parkManual( + journalEntry.intent === 'remove' + ? TPSL_GUARDED_REMOVAL_REVIEW_REASON + : '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', + ); + } + } + // Persist terminal successor disposition before journal removal so a + // restarted caller cannot replay an already completed explicit intent. + await this.#verifyRetainedRecoveryCoverage( + journalEntry, + readActiveRaw, + readInactiveFor, + ); + await this.#finishRecoverySuccessor( + journalEntry, + journalEntry.intent === 'remove' || + anySuccess || + (anyActive && !anyFailed) + ? 'settled' + : 'failed', + await readActiveRaw(), + context.generation, + ); + // A refused clear (superseded by a newer operation) is UNRESOLVED — + // never reported as success. + return await this.#clearTpslJournal( + settlementKey, + journalEntry.operationId, + await readActiveRaw(), + ); + }; + + /** + * 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). + * @param apiKeyIndex - Original attempt slot, independent of the successor key. + * @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 }, + apiKeyIndex = this.#apiKeyIndex, + ): Promise<'resolved' | 'unresolved'> => { + const generation = this.#sessionGeneration; + this.#assertSession(generation); + for (const attempt of entry.attempts) { + if (attempt.kind === 'create') { + delete attempt.neverLanded; + } + } + // 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); + this.#assertSession(generation); + } 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 status (0) resolves the attempt + // deterministically — the nonce was consumed but the books + // were never mutated (the machine re-acts on book state); + // - executed (2) or pending-final (3) with books 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 === 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'; + } + const transactionOutcome = getLighterTransactionOutcome( + lookedUp.status, + ); + if (transactionOutcome === 'failed') { + // 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 ( + (transactionOutcome === 'executed' || + transactionOutcome === 'pending-final') && + satisfiedOnBooks(attempt, rawActive, rawInactive) + ) { + continue; + } + // Executed proves the transaction landed, but the order books may + // still lag. Keep the journal until the expected create/cancel state + // is visible. + return 'unresolved'; + } + // Venue-confirmed not-found: only never-landed once the signed + // payload can no longer be accepted. + if ( + attempt.outcome === 'accepted' || + Date.now() <= attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + // Observed acceptance cannot become never-landed due to a later + // missing lookup or book lag. Retain its obligation until proven. + return 'unresolved'; + } + if (attempt.kind === 'create') { + attempt.neverLanded = true; + } + // 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). + this.#assertSession(generation); + await this.#releaseNonceReservationIfUnconsumed( + accountIndex, + attempt.nonce, + apiKeyIndex, + ); + } + 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 apiKeyIndex - Key slot captured for this operation. + * @param accountIndex - Venue account index. + * @param nonce - The proven-unconsumed nonce. + */ + readonly #releaseNonceReservation = ( + accountIndex: number, + nonce: number, + apiKeyIndex = this.#apiKeyIndex, + ): void => { + const reservationKey = `${accountIndex}:${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). + * @param options.receiptIdentity - Account and market bound to a new receipt. + * @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[][]; + /** Require exact venue IDs for the current operation receipt. */ + receiptIdentity?: { accountIndex: number; marketIndex: number }; + } = {}, + ): Promise< + | { outcome: 'settled'; executedCreated: boolean; childOrderIds: string[] } + | { + 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) + : []; + const childOrderIds: string[] = []; + if (options.receiptIdentity) { + for (const clientId of expectation.createdClientIds) { + const active = rawActive.filter( + (row) => String(row.clientOrderIndex) === String(clientId), + ); + const matches = + active.length > 0 + ? active + : rawInactive.filter( + (row) => String(row.clientOrderIndex) === String(clientId), + ); + if (matches.length === 0) { + continue; // Existing settlement polling retains the obligation. + } + const [row] = matches; + if ( + matches.length !== 1 || + row.ownerAccountIndex !== options.receiptIdentity.accountIndex || + row.marketIndex !== options.receiptIdentity.marketIndex || + !Number.isSafeInteger(row.orderIndex) || + row.orderIndex <= 0 || + childOrderIds.includes(String(row.orderIndex)) + ) { + throw new Error( + 'Lighter TP/SL receipt identity is invalid or ambiguous', + ); + } + childOrderIds.push(String(row.orderIndex)); + } + } + // 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, + childOrderIds, + }; + } + 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 (this.#isDisconnected) { + throw new LighterSessionCancelledError( + 'the Lighter provider was disconnected', + ); + } + if (generation !== this.#sessionGeneration) { + throw new LighterSessionCancelledError( + '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 + // 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 LighterSessionCancelledError( + '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 LighterSessionCancelledError( + '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.#interruptChase(); + this.#sessionGeneration += 1; + this.#boundAddress = null; + this.#accountIndex = null; + this.#apiKeyIndex = this.#preferredApiKeyIndex; + this.#signerReadyPromise = null; + this.#readyApiKeyIndex = 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(); + this.#assertSession(this.#sessionGeneration); + if (this.#signerReadyPromise) { + return await this.#signerReadyPromise; + } + const generation = this.#sessionGeneration; + this.#readyApiKeyIndex = null; + 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; + this.#readyApiKeyIndex = null; + this.#apiKeyIndex = this.#preferredApiKeyIndex; + this.#clearBridgeOwnership(); + } + throw error; + } + }; + + /** + * Account-scoped storage is independent of whichever slot setup selects. + * @param accountIndex - Venue account that owns the pending registration. + * @returns Wallet/network/account-scoped storage key. + */ + readonly #pendingKeyRegistrationKey = (accountIndex: number): string => + `lighterKeyRegistration:${this.#isTestnet ? 'testnet' : 'mainnet'}:${this.#boundAddress}:${accountIndex}`; + + /** + * Load the outstanding registration before choosing any unused slot. + * @param accountIndex - Captured venue account. + * @returns Valid durable identity, or null when no registration is pending. + */ + readonly #loadPendingKeyRegistration = async ( + accountIndex: number, + ): Promise => { + let raw: string | null; + try { + raw = await this.#deps.diskCache.getItem( + this.#pendingKeyRegistrationKey(accountIndex), + ); + } catch (error) { + throw new Error( + `Lighter pending key registration read failed: ${ensureError(error, 'LighterProvider.#loadPendingKeyRegistration').message}`, + ); + } + if (raw === null) { + return null; + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error( + 'Lighter pending key registration is corrupt; resolve storage before reconnecting', + ); + } + if (typeof value === 'object' && value !== null) { + const doc = value as Record; + if ( + doc.version === 1 && + doc.accountIndex === accountIndex && + typeof doc.apiKeyIndex === 'number' && + Number.isSafeInteger(doc.apiKeyIndex) && + doc.apiKeyIndex >= LIGHTER_MIN_TRADING_API_KEY_INDEX && + doc.apiKeyIndex <= LIGHTER_MAX_TRADING_API_KEY_INDEX && + typeof doc.publicKey === 'string' && + /^[0-9a-f]{80}$/u.test(doc.publicKey) && + typeof doc.txHash === 'string' && + /^(0x)?[0-9a-fA-F]{8,128}$/u.test(doc.txHash) && + typeof doc.nonce === 'number' && + Number.isSafeInteger(doc.nonce) && + doc.nonce >= 0 && + typeof doc.expiresAt === 'number' && + Number.isSafeInteger(doc.expiresAt) && + doc.expiresAt > 0 && + typeof doc.accepted === 'boolean' + ) { + return { + version: 1, + accountIndex, + apiKeyIndex: doc.apiKeyIndex, + publicKey: doc.publicKey, + txHash: doc.txHash, + nonce: doc.nonce, + expiresAt: doc.expiresAt, + accepted: doc.accepted, + }; + } + } + throw new Error( + 'Lighter pending key registration is corrupt; resolve storage before reconnecting', + ); + }; + + /** + * Reconcile an invisible registration without signing or changing slots. + * Caller holds the account write mutex. Accepted registrations never become + * retryable merely because a later transaction lookup cannot find them. + * @param pending - Exact durable registration identity. + * @param generation - Captured wallet session. + */ + readonly #reconcilePendingKeyRegistration = async ( + pending: LighterPendingKeyRegistration, + generation: number, + ): Promise => { + const tx = await this.#clientService.getTx(pending.txHash); + this.#assertSession(generation); + const matches = + tx !== null && + tx.hash?.toLowerCase().replace(/^0x/u, '') === + pending.txHash.toLowerCase().replace(/^0x/u, '') && + tx.accountIndex === pending.accountIndex && + tx.apiKeyIndex === pending.apiKeyIndex && + tx.nonce === pending.nonce; + const failed = + matches && getLighterTransactionOutcome(tx.status) === 'failed'; + if ( + matches && + getLighterTransactionOutcome(tx.status) === 'executed' && + !pending.accepted + ) { + // Retain the strongest observed outcome before another reconnect can + // mistake expired index absence for proof that this registration failed. + pending.accepted = true; + await this.#deps.diskCache.setItem( + this.#pendingKeyRegistrationKey(pending.accountIndex), + JSON.stringify(pending), + ); + this.#assertSession(generation); + } + + let expiredUnsent = false; + if ( + !pending.accepted && + tx === null && + Date.now() > pending.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + const ledger = await this.#readNonceLedger( + pending.accountIndex, + pending.apiKeyIndex, + ); + this.#assertSession(generation); + expiredUnsent = + ledger.consumedFloor <= pending.nonce && + !ledger.recovered.some( + (entry) => + entry.txHash === pending.txHash && entry.outcome !== 'failed', + ); + } + if (failed || expiredUnsent) { + await this.#deps.diskCache.removeItem( + this.#pendingKeyRegistrationKey(pending.accountIndex), + ); + this.#assertSession(generation); + return; + } + throw new Error( + 'Lighter trading key registration is still pending; reconnect to check its status', + ); + }; + + /** + * Find local keys first, then unused slots. Never replace a venue key. + * + * @param accountIndex - Venue account whose local keys may be reused. + * @param generation - Session captured before storage and venue reads. + * @returns Locally persisted slots followed by unused trading slots. + */ + readonly #signerCandidates = async ( + accountIndex: number, + generation: number, + ): Promise => { + const pending = await this.#loadPendingKeyRegistration(accountIndex); + this.#assertSession(generation); + if (pending) { + return [pending.apiKeyIndex]; + } + const bridge = this.#getSignerBridge(); + const discoverKeys = + bridge.getRecoverableKeyIndices?.bind(bridge) ?? + bridge.getStoredKeyIndices?.bind(bridge); + if (!discoverKeys) { + return [this.#preferredApiKeyIndex]; + } + const response = await this.#clientService.getApiKeys(accountIndex); + this.#assertSession(generation); + const occupied = new Set(response.apiKeys.map((key) => key.apiKeyIndex)); + const requested = [ + ...new Set([this.#preferredApiKeyIndex, ...occupied]), + ].filter( + (index) => + Number.isSafeInteger(index) && + index >= LIGHTER_MIN_TRADING_API_KEY_INDEX && + index <= LIGHTER_MAX_TRADING_API_KEY_INDEX, + ); + const stored = await discoverKeys({ + chainId: getLighterChainId(this.#clientService.network), + accountIndex, + apiKeyIndices: requested, + walletAddress: this.#boundAddress ?? undefined, + }); + this.#assertSession(generation); + if (stored.some((index) => !requested.includes(index))) { + throw new Error('Lighter signer returned an unrequested key slot'); + } + const unused = Array.from( + { length: LIGHTER_TRADING_API_KEY_COUNT }, + (_, index) => index + LIGHTER_MIN_TRADING_API_KEY_INDEX, + ).filter((index) => !occupied.has(index)); + if ( + stored.length === 0 && + occupied.has(this.#preferredApiKeyIndex) && + unused.length > 0 + ) { + throw new Error( + 'No recoverable Lighter trading key is available from this wallet or local storage. Use an existing registered device.', + ); + } + // Wallet-derived recovery uses a stable free-slot order. Storage-only + // discovery rotates unused slots; venue public keys are always checked. + const offset = bridge.getRecoverableKeyIndices + ? 0 + : Math.floor(Math.random() * unused.length); + const randomized = [...unused.slice(offset), ...unused.slice(0, offset)]; + return [ + ...new Set([ + ...stored.filter((index) => occupied.has(index)), + ...stored.filter((index) => !occupied.has(index)), + ...(requested.includes(this.#preferredApiKeyIndex) && + !occupied.has(this.#preferredApiKeyIndex) + ? [this.#preferredApiKeyIndex] + : []), + ...randomized, + ]), + ]; + }; + + readonly #setupSigner = async (generation: number): Promise => { + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + // Keep discovery and allocation together across provider lifetimes. The + // inner venue lock separately protects each candidate's financial writes. + await withProcessMutex( + `lighterKeySetup:${this.#clientService.network}:${accountIndex}`, + async () => this.#setupSignerForAccount(accountIndex, generation), + ); + }; + + /** + * Select and settle a key while holding the account setup mutex. + * @param accountIndex - Venue account bound to this setup. + * @param generation - Wallet session captured before waiting for the mutex. + */ + readonly #setupSignerForAccount = async ( + accountIndex: number, + generation: number, + ): Promise => { + this.#assertSession(generation); + const bridge = this.#getSignerBridge(); + const chainId = getLighterChainId(this.#clientService.network); + const candidates = await this.#signerCandidates(accountIndex, generation); + for (const apiKeyIndex of candidates) { + let candidateNonce = 0; + let changePubKeyBody = ''; + let candidateStatus: 'available' | 'matching' = 'matching'; + // Inspect identity under the same slot and bridge locks as writes. + // An unrelated key's ledger must not block discovery of our key. + const ready = await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + if (candidateStatus === 'available') { + // Reconciliation can raise the nonce floor. The registration + // plaintext must bind to the safe nonce, not the probe's seed. + const nonce = await nextNonce(); + if (nonce !== candidateNonce) { + const created = await bridge.createClient({ + chainId, + accountIndex, + nonce, + apiKeyIndex, + walletAddress: this.#boundAddress ?? undefined, + }); + this.#assertSession(generation); + if ( + created.error || + !created.success || + created.pk !== this.#venuePublicKey + ) { + throw new Error( + 'Lighter signer identity changed during registration preparation', + ); + } + changePubKeyBody = created.body; + } + await this.#registerVenueKey( + accountIndex, + changePubKeyBody, + generation, + async () => nonce, + submit, + ); + this.#assertSession(generation); + if (bridge.getRecoverableKeyIndices || bridge.getStoredKeyIndices) { + await this.#waitForRegisteredKey(accountIndex, generation); + } + } + return true; + }, + generation, + apiKeyIndex, + async () => { + // Candidate selection can race a second provider's completed setup. + // Recheck under the account mutex before creating or registering a key. + const pending = await this.#loadPendingKeyRegistration(accountIndex); + this.#assertSession(generation); + if (pending && pending.apiKeyIndex !== apiKeyIndex) { + throw new Error( + 'Lighter trading key registration is still pending; reconnect to check its status', + ); + } + const nonceResponse = await this.#clientService.getNextNonce( + accountIndex, + apiKeyIndex, + ); + this.#assertSession(generation); + candidateNonce = nonceResponse.nonce; + const created = await bridge.createClient({ + chainId, + accountIndex, + nonce: candidateNonce, + apiKeyIndex, + walletAddress: this.#boundAddress ?? undefined, + }); + if (created.error || !created.success) { + throw new Error( + `Lighter signer client creation failed: ${created.error ?? 'unknown'}`, + ); + } + this.#assertSession(generation); + if ( + pending && + created.pk.replace(/^0x/u, '').toLowerCase() !== pending.publicKey + ) { + throw new Error( + 'Lighter pending registration belongs to a different local key; restore the original signer before reconnecting', + ); + } + this.#venuePublicKey = created.pk; + this.#signerIdentity = `${this.#clientService.network}:${accountIndex}:${apiKeyIndex}`; + this.#signerRecreateParams = { chainId, accountIndex }; + bridgeClientOwners.set(this.#rawSignerBridge(), this.#signerIdentity); + changePubKeyBody = created.body; + const status = await this.#venueKeyStatus(accountIndex); + this.#assertSession(generation); + if (pending) { + if (status === 'matching') { + await this.#deps.diskCache.removeItem( + this.#pendingKeyRegistrationKey(accountIndex), + ); + this.#assertSession(generation); + } else if (status === 'available') { + await this.#reconcilePendingKeyRegistration(pending, generation); + } else { + throw new Error( + 'Lighter trading key changed during pending registration; reconcile the original slot before reconnecting', + ); + } + } + if (status === 'occupied') { + this.#clearBridgeOwnership(); + if ( + !bridge.getRecoverableKeyIndices && + !bridge.getStoredKeyIndices + ) { + throw new Error( + `Lighter API key slot ${String(apiKeyIndex)} already contains a different key; reconnect with a client that supports device-key recovery`, + ); + } + return { result: false }; + } + candidateStatus = status; + }, + ); + if (ready) { + this.#assertSession(generation); + this.#readyApiKeyIndex = apiKeyIndex; + this.#kickTpslRecovery(); + return; + } + } + throw new Error( + 'No available Lighter trading key slot. Remove an unused API key in Lighter, then reconnect.', + ); + }; + + readonly #venueKeyStatus = async ( + accountIndex: number, + ): Promise<'available' | 'matching' | 'occupied'> => { + const response = await this.#clientService.getApiKeys(accountIndex); + const configuredSlot = response.apiKeys.find( + (key) => key.apiKeyIndex === this.#apiKeyIndex, + ); + if (!configuredSlot) { + return 'available'; + } + const normalizeKey = (key: string | null): string => + (key ?? '').replace(/^0x/u, '').toLowerCase(); + return normalizeKey(configuredSlot.publicKey) === + normalizeKey(this.#venuePublicKey) + ? 'matching' + : 'occupied'; + }; + + /** + * Wait for registration visibility before authenticating against a new slot. + * A successful send only establishes acceptance, not indexed read readiness. + * + * @param accountIndex - Account whose registration was just submitted. + * @param generation - Session that owns the registration. + */ + readonly #waitForRegisteredKey = async ( + accountIndex: number, + generation: number, + ): Promise => { + const deadline = + Date.now() + LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS; + for ( + let attempt = 0; + attempt < LIGHTER_KEY_REGISTRATION_VISIBILITY_MAX_ATTEMPTS; + attempt += 1 + ) { 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 remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + let timer: ReturnType | undefined; + const status = await Promise.race([ + this.#venueKeyStatus(accountIndex), + new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), remaining); + }), + ]).finally(() => clearTimeout(timer)); + this.#assertSession(generation); + if (status === 'pending') { + break; + } + if (status === 'matching') { + await this.#deps.diskCache.removeItem( + this.#pendingKeyRegistrationKey(accountIndex), + ); + this.#assertSession(generation); + return; + } + if (status === 'occupied') { + throw new Error('Lighter trading key changed during registration'); + } + await new Promise((resolve) => + setTimeout(resolve, LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS), + ); + } + throw new Error( + 'Lighter trading key registration is still pending; reconnect to check its status', + ); + }; + + 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; + onDispatch?: () => void; + }, + ) => 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 identity = extractDispatchIdentity(signed); + let result: LighterSendTxResponse; + if (!bridge.getRecoverableKeyIndices && !bridge.getStoredKeyIndices) { + // Legacy bridges use only their explicitly configured slot. Preserve + // that contract; durable allocation tracking applies to discovery. + result = await submit( + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + signed.txInfo, + undefined, + identity, + ); + } else { + if (identity.txHash === null || identity.expiresAt === null) { + throw new Error( + 'Lighter registration signing result has no complete transaction identity', + ); + } + const registrationKey = this.#pendingKeyRegistrationKey(accountIndex); + const pending: LighterPendingKeyRegistration = { + version: 1, + accountIndex, + apiKeyIndex: this.#apiKeyIndex, + publicKey: (this.#venuePublicKey ?? '') + .replace(/^0x/u, '') + .toLowerCase(), + txHash: identity.txHash, + nonce, + expiresAt: identity.expiresAt, + accepted: false, + }; + await this.#deps.diskCache.setItem( + registrationKey, + JSON.stringify(pending), + ); + let dispatched = false; + try { + this.#assertSession(generation); + result = await submit( + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + signed.txInfo, + () => { + pending.accepted = true; + }, + { + ...identity, + onDispatch: () => { + dispatched = true; + }, + }, + ); + } catch (error) { + if (!dispatched) { + await this.#deps.diskCache.removeItem(registrationKey); + } else if (pending.accepted) { + await this.#deps.diskCache.setItem( + registrationKey, + JSON.stringify(pending), + ); + } + throw error; + } + await this.#deps.diskCache.setItem( + registrationKey, + JSON.stringify(pending), + ); + this.#assertSession(generation); + } + 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). + * @param apiKeyIndex - Slot captured when this section was queued. + * @param inspectCandidate - Optional read-only identity inspection before + * ledger resolution. A nonmatching candidate may return without reading or + * changing its unrelated ledger. Matching/available candidates retain all + * nonce safeguards before the write section runs. + * @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; + beforeDispatch?: () => Promise; + onNotDispatched?: () => Promise; + requireExecution?: boolean; + onDispatch?: () => void; + afterAccepted?: () => Promise; + }, + ) => Promise, + ) => Promise, + generationAtIntent = this.#sessionGeneration, + apiKeyIndex = this.#apiKeyIndex, + inspectCandidate?: () => Promise<{ result: Result } | void>, + ): Promise => { + const criticalSection = async (): Promise => { + this.#assertSession(generationAtIntent); + if (this.#apiKeyIndex !== apiKeyIndex) { + this.#clearBridgeOwnership(); + } + this.#apiKeyIndex = apiKeyIndex; + if (inspectCandidate) { + const inspected = await inspectCandidate(); + this.#assertSession(generationAtIntent); + if (inspected) { + return inspected.result; + } + } + await this.#rememberRecoveryAccount(accountIndex, generationAtIntent); + 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. + // Key discovery/registration must remain possible without touching an + // unrelated candidate's obligations. Financial operations, however, + // cannot escape account-local quarantine by switching signing slots. + if (!inspectCandidate) { + for ( + let previousSlot = LIGHTER_MIN_TRADING_API_KEY_INDEX; + previousSlot <= LIGHTER_MAX_TRADING_API_KEY_INDEX; + previousSlot += 1 + ) { + this.#assertSession(generationAtIntent); + if (previousSlot !== apiKeyIndex) { + await this.#resolveNonceLedger(accountIndex, previousSlot); + this.#assertSession(generationAtIntent); + } + } + } + await this.#resolveNonceLedger(accountIndex, apiKeyIndex); + 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}:${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, + 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; + beforeDispatch?: () => Promise; + onNotDispatched?: () => Promise; + requireExecution?: boolean; + onDispatch?: () => void; + afterAccepted?: () => Promise; + }, + ): 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, + apiKeyIndex, + ); + 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, + }, + apiKeyIndex, + ); + }, + apiKeyIndex, + ); + // 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. + // A switch after append leaves the unsent entry for conservative + // reconciliation. It may block until signed expiry plus clock slack; + // retaining uncertainty avoids treating a durable append as absent. + try { + await identity?.beforeDispatch?.(); + this.#assertSession(generationAtIntent); + } catch (error) { + // No transport call has occurred. Keep the final after-persistence + // guard, but release only this exact proven-unsent attempt. A stale + // session retains quarantine rather than touching another scope. + this.#assertSession(generationAtIntent); + try { + await identity?.onNotDispatched?.(); + } catch (cleanupError) { + this.#assertSession(generationAtIntent); + this.#deps.debugLogger.log( + '[LighterProvider] Proven-unsent dispatch cleanup remains pending', + { error: String(cleanupError) }, + ); + // Keep the ledger quarantined when its owner could not retire. + throw error; + } + this.#assertSession(generationAtIntent); + const unsentEntry = ledgerEntry; + if (unsentEntry) { + await this.#withLedgerLock( + accountIndex, + async () => { + this.#assertSession(generationAtIntent); + const doc = await this.#readNonceLedger( + accountIndex, + apiKeyIndex, + ); + this.#assertSession(generationAtIntent); + await this.#writeNonceLedger( + accountIndex, + { + ...doc, + entries: doc.entries.filter( + (entry) => + entry.nonce !== unsentEntry.nonce || + entry.txHash !== unsentEntry.txHash || + entry.owner !== unsentEntry.owner, + ), + }, + apiKeyIndex, + ); + this.#assertSession(generationAtIntent); + if (unsentEntry.nonce >= doc.consumedFloor) { + this.#releaseNonceReservation( + accountIndex, + unsentEntry.nonce, + apiKeyIndex, + ); + } + }, + apiKeyIndex, + ); + } + throw error; + } + identity?.onDispatch?.(); + const response: LighterSendTxResponse = + await this.#clientService.sendTx(txType, txInfo); + if (identity?.requireExecution) { + // Transport acceptance cannot retire additive collateral intent. + // Keep its durable record until its exact transaction executes. + this.#assertSession(generationAtIntent); + let executed = false; + for ( + let attempt = 0; + attempt < LIGHTER_MARGIN_EXECUTION_ATTEMPTS; + attempt += 1 + ) { + this.#assertSession(generationAtIntent); + const transaction = ledgerEntry?.txHash + ? await this.#clientService.getTx(ledgerEntry.txHash) + : null; + this.#assertSession(generationAtIntent); + const matches = + ledgerEntry !== null && + transaction !== null && + typeof transaction.hash === 'string' && + transaction.hash.toLowerCase().replace(/^0x/u, '') === + ledgerEntry.txHash?.toLowerCase().replace(/^0x/u, '') && + transaction.accountIndex === accountIndex && + transaction.apiKeyIndex === apiKeyIndex && + transaction.nonce === ledgerEntry.nonce; + const outcome = matches + ? getLighterTransactionOutcome(transaction.status) + : null; + if (outcome === 'failed') { + throw new Error( + 'Lighter margin transaction failed; refresh its exact outcome before retrying', + ); + } + if (outcome === 'executed') { + executed = true; + break; + } + if (attempt + 1 < LIGHTER_MARGIN_EXECUTION_ATTEMPTS) { + await new Promise((resolve) => + setTimeout(resolve, LIGHTER_MARGIN_EXECUTION_POLL_MS), + ); + this.#assertSession(generationAtIntent); + } + } + if (!executed) { + throw new Error( + 'Lighter margin mode update remains unresolved or margin adjustment is pending; review its exact transaction before retrying', + ); } } + // 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?.(); + if (identity?.afterAccepted) { + await identity.afterAccepted(); + } + // 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, + apiKeyIndex, + ).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 across key slots: 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}`, + // 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; + beforeDispatch?: () => Promise; + onNotDispatched?: () => Promise; + requireExecution?: boolean; + onDispatch?: () => void; + afterAccepted?: () => Promise; + }, + ) => 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 bridge owns key generation and persistence. + * + * @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', + ); + } + this.#assertSession(generation); + const recreated = await this.#getSignerBridge().createClient({ + chainId: recreateParams.chainId, + accountIndex: recreateParams.accountIndex, + nonce, + apiKeyIndex: this.#apiKeyIndex, + walletAddress: this.#boundAddress ?? undefined, + }); + 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 ( + forceRefresh = false, + ): Promise> => { + if (forceRefresh || this.#marketsBySymbol.size === 0) { + const result = await this.initialize(); + if (forceRefresh && !result.success) { + throw new Error(result.error ?? 'Lighter market metadata unavailable'); + } + } + return this.#marketsBySymbol; + }; + + // ============================================================================ + // Market Data Operations (Public Reads) + // ============================================================================ + + /** + * Report native standalone triggers for an active, known perpetual market. Explicit + * margin modes and strategies remain unreported until their write paths exist. + * + * @param params - Market route to inspect. + * @returns Native trigger types or the reason the market is unavailable. + */ + async getOrderCapabilities( + params: GetOrderCapabilitiesParams, + ): Promise { + const unavailable = ( + reason: DirectProviderOrderCapabilitiesUnavailableReason, + ): DirectProviderOrderCapabilities => + Object.freeze({ + status: 'unavailable', + providerId: this.protocolId, + reason, + }); + if (!params.symbol.trim()) { + return unavailable('invalid_symbol'); + } + if (this.#isDisconnected) { + return unavailable('provider_unavailable'); + } + let markets: Map; + try { + markets = await this.#ensureMarkets(true); + } catch { + return unavailable('provider_unavailable'); + } + if (this.#isDisconnected) { + return unavailable('provider_unavailable'); + } + const market = markets.get(params.symbol); + if (!market) { + return unavailable('market_not_found'); + } + if (market.status !== 'active' || market.marketType !== 'perp') { + return unavailable('order_market_unsupported'); + } + return Object.freeze({ + status: 'ready', + providerId: this.protocolId, + supportedStrategies: Object.freeze( + this.#chaseTestnetProbe && + this.#isTestnet && + this.#signerBridge !== null + ? (['scale', 'chase'] as const) + : (['scale'] as const), + ), + supportedMarginModes: Object.freeze(['cross', 'isolated'] as const), + supportedTriggerOrderTypes: Object.freeze([...TRIGGER_ORDER_TYPES]), + attachedTpsl: Object.freeze({ + submission: 'native-oto-otoco', + childCoverage: 'venue-native-zero-size', + partialSizes: false, + lifecycleVerification: 'pending', + cancellation: 'explicit-exact-owned-orders', + }), + positionTpsl: Object.freeze({ + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_market', + stopLossOrderType: 'stop_market', + defaultCoverage: 'position-snapshot', + partialCoverage: Object.freeze({ + single: true, + pair: 'equal-quantity-oco', + supportedPairs: Object.freeze([ + 'equal-quantity-oco', + 'independent', + ] as const), + replacement: 'cancel-before-create', + recovery: 'explicit-current-position-intent', + }), + }), + }); + } + + /** Normalize a Scale preview without account or signer setup. + * @param params - Market, price range and optional sizing intent. + * @returns Fixed-grid prices and requested sizing, or unavailable scope. + */ + async getScalePriceLadder( + params: GetScalePriceLadderParams, + ): Promise { + const intent = { + ...params, + sizing: params.sizing === undefined ? undefined : { ...params.sizing }, + }; + if ( + intent.providerId !== undefined && + intent.providerId !== this.protocolId + ) { + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'provider_not_routable', }; - const firstPage = await this.#clientService.getInactiveOrders( - accountIndex, - authToken, - 100, - undefined, - marketId, + } + const capability = await this.getOrderCapabilities(intent); + if (capability.status !== 'ready') { + const reason = + capability.reason === 'order_market_unsupported' || + capability.reason === 'strategy_market_unsupported' + ? 'market_not_found' + : capability.reason; + return { status: 'unavailable', providerId: this.protocolId, reason }; + } + const market = this.#marketsBySymbol.get(intent.symbol); + if (!market) { + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'market_not_found', + }; + } + if (intent.sizing !== undefined) { + const { sizing } = intent; + if ((sizing.size !== undefined) === (sizing.usdAmount !== undefined)) { + throw new Error( + 'Lighter Scale sizing requires exactly one exposure intent', + ); + } + const ladder = buildLighterScaleLadder( + { + symbol: intent.symbol, + orderType: 'scale', + scaleMinPrice: String(intent.minPrice), + scaleMaxPrice: String(intent.maxPrice), + scaleNumOrders: intent.count, + size: sizing.size, + usdAmount: sizing.usdAmount, + scaleSkew: sizing.skew, + }, + market, ); - this.#assertSession(generation); - let rowsRead = firstPage.orders.length; - if (rowsRead > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { + return { + status: 'ready', + providerId: this.protocolId, + prices: ladder.prices, + sizingPreview: { + sizes: ladder.sizes, + totalSize: ladder.size, + totalNotional: ladder.notional, + minimumBaseSize: market.minBaseAmount, + minimumQuoteAmount: market.minQuoteAmount, + sizeDecimals: market.supportedSizeDecimals, + }, + }; + } + return { + status: 'ready', + providerId: this.protocolId, + prices: normalizeLighterScalePrices( + String(intent.minPrice), + String(intent.maxPrice), + intent.count, + market.supportedPriceDecimals, + ), + }; + } + + readonly #scaleKey = (accountIndex: number): string => + `${LIGHTER_SCALE_JOURNAL_PREFIX}${this.#isTestnet ? 'testnet' : 'mainnet'}:${this.#boundAddress}:${accountIndex}`; + + readonly #readScaleGroups = async ( + key: string, + accountIndex: number, + ): Promise => { + let groups: LighterScaleGroup[]; + try { + groups = parseLighterScaleGroups(await this.#deps.diskCache.getItem(key)); + } catch { + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} Lighter Scale journal is unavailable or invalid`, + ); + } + const scope = `${LIGHTER_SCALE_PREFIX}${key.slice(LIGHTER_SCALE_JOURNAL_PREFIX.length)}:`; + for (const group of groups) { + if ( + group.accountIndex !== accountIndex || + group.walletAddress !== this.#boundAddress || + group.network !== (this.#isTestnet ? 'testnet' : 'mainnet') || + group.groupId !== `${scope}${group.rungs[0].clientOrderId}` + ) { throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history exceeded ${LIGHTER_INACTIVE_HISTORY_ROW_LIMIT} rows; TP/SL recovery is still pending`, + `${LIGHTER_DATA_INTEGRITY_PREFIX} Lighter Scale ownership belongs to another account`, ); } - ingest(firstPage.orders); - if (missing() && !deepTraversalDone) { - let cursor = firstPage.nextCursor; - const seenCursors = new Set(); - let pagesRead = 1; - while (cursor && missing()) { - if (seenCursors.has(cursor)) { - throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history repeated cursor ${cursor} after ${rowsRead} rows; TP/SL recovery is still pending`, - ); - } - seenCursors.add(cursor); - const response = await this.#clientService.getInactiveOrders( - accountIndex, - authToken, - 100, - cursor, - marketId, - ); - this.#assertSession(generation); - pagesRead += 1; - if ( - response.orders.length === 0 && - response.nextCursor !== undefined - ) { - throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history advanced without returning rows; TP/SL recovery is still pending`, - ); - } - if (pagesRead > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { - throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history exceeded ${LIGHTER_INACTIVE_HISTORY_ROW_LIMIT} pages; TP/SL recovery is still pending`, - ); - } - rowsRead += response.orders.length; - if (rowsRead > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { - throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} inactive-order history exceeded ${LIGHTER_INACTIVE_HISTORY_ROW_LIMIT} rows (${rowsRead} rows returned); TP/SL recovery is still pending`, - ); + group.rungs.forEach((rung) => + this.#issuedClientOrderIds.add(rung.clientOrderId), + ); + } + return groups; + }; + + readonly #writeScaleGroup = async ( + key: string, + group: LighterScaleGroup, + generation: number, + ): Promise => { + await withStorageMutex(key, async () => { + this.#assertSession(generation); + const groups = await this.#readScaleGroups(key, group.accountIndex); + this.#assertSession(generation); + const index = groups.findIndex((item) => item.groupId === group.groupId); + if (index < 0) { + if (groups.length >= LIGHTER_SCALE_MAX_GROUPS) { + const terminal = groups.findIndex(isLighterScaleTerminal); + if (terminal < 0) { + throw new Error('Lighter Scale ownership is full'); } - ingest(response.orders); - cursor = response.nextCursor; + groups.splice(terminal, 1); } - deepTraversalDone = !cursor || !missing(); + groups.push(group); + } else { + if (JSON.stringify(groups[index]) === JSON.stringify(group)) { + return; + } + groups[index] = group; } - return [...terminalCache.values()]; - }; + const serialized = JSON.stringify(groups); + parseLighterScaleGroups(serialized); + await this.#deps.diskCache.setItem(key, serialized); + this.#assertSession(generation); + }); }; - /** 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 #recordScaleCancellationAcknowledgement = async ( + key: string, + group: LighterScaleGroup, + rung: LighterScaleRung, + attempt: NonNullable, + acknowledged: boolean, + ): Promise => { + // Completion evidence belongs to its captured owner even after a session + // switch. This updates only an already-persisted exact attempt, never authority. + await withStorageMutex(key, async () => { + const groups = parseLighterScaleGroups( + await this.#deps.diskCache.getItem(key), + ); + const current = groups.find((entry) => entry.groupId === group.groupId); + if ( + !current || + current.walletAddress !== group.walletAddress || + current.network !== group.network || + current.accountIndex !== group.accountIndex || + current.marketId !== group.marketId || + current.apiKeyIndex !== group.apiKeyIndex + ) { + throw new Error('Lighter Scale acknowledgement ownership mismatch'); + } + const child = current.rungs.find( + (entry) => entry.clientOrderId === rung.clientOrderId, + ); + const saved = child?.cancelAttempt; + if (saved === undefined) { + return; + } + if ( + child?.orderId !== rung.orderId || + saved.apiKeyIndex !== attempt.apiKeyIndex || + saved.nonce !== attempt.nonce || + saved.txHash !== attempt.txHash || + saved.expiresAt !== attempt.expiresAt + ) { + throw new Error('Lighter Scale acknowledgement attempt mismatch'); + } + if (saved.acknowledged === true) { + return; + } + saved.acknowledged = acknowledged; + const serialized = JSON.stringify(groups); + parseLighterScaleGroups(serialized); + await this.#deps.diskCache.setItem(key, serialized); + }); + }; - readonly #kickTpslRecovery = (): void => { - if (this.#tpslRecoveryGeneration === this.#sessionGeneration) { - return; + readonly #readScaleReservations = async ( + accountIndex: number, + marketId: number, + isBuy: boolean, + token: string, + generation: number, + ): Promise<{ reserved: BigNumber; identities: Set }> => { + const active = await this.#clientService.getActiveOrders( + accountIndex, + token, + marketId, + ); + this.#assertSession(generation); + if (!Array.isArray(active.orders)) { + throw new Error('Lighter Scale active reservations are unavailable'); } - 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; + let reserved = new BigNumber(0); + const identities = new Set(); + for (const row of active.orders) { + if ( + row.ownerAccountIndex !== accountIndex || + row.marketIndex !== marketId || + ![0, 1, false, true].includes(row.reduceOnly) + ) { + throw new Error('Invalid Lighter Scale active reservation scope'); + } + const identity = String(row.orderIndex); + if (identities.has(identity)) { + throw new Error('Duplicate Lighter Scale active reservation'); + } + identities.add(identity); + if (!row.reduceOnly) { + continue; + } + const quantity = parseStrictDecimal(row.remainingBaseAmount); + if (quantity === null || quantity < 0 || row.isAsk !== !isBuy) { + throw new Error('Invalid Lighter Scale reduce-only reservation'); + } + reserved = reserved.plus(row.remainingBaseAmount); } - setTimeout(() => { - this.#recoverPendingTpslJournals().catch((error) => { - this.#deps.debugLogger.log( - '[LighterProvider] TP/SL journal recovery failed', - { error: String(error) }, - ); - }); - }, 0); + return { reserved, identities }; }; - /** - * 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; + readonly #prepareScaleOrder = async ( + params: OrderParams, + generation: number, + readToken?: string, + ): Promise<{ + market: LighterOrderBookMeta; + ladder: ReturnType; + accountIndex: number; + }> => { + const leverageError = lighterLeverageError(params.leverage); + if (leverageError) { + throw new Error(leverageError); } - if (this.#tpslRecoveryInFlight) { - await this.#tpslRecoveryInFlight; - return; + const markets = await this.#ensureMarkets(true); + this.#assertSession(generation); + const market = markets.get(params.symbol); + if (!market) { + throw new Error('Unknown Lighter Scale market'); } - 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(); - } + let ladder: ReturnType; + try { + ladder = buildLighterScaleLadder(params, market); + } catch (error) { + if (params.expectedScaleLadder !== undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE); } - })(); - await this.#tpslRecoveryInFlight; + throw error; + } + assertExpectedScaleLadder(params.expectedScaleLadder, ladder, market); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const response = await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generation); + const account = response.accounts?.[0]; + if ( + !Array.isArray(response.accounts) || + response.accounts.length !== 1 || + !account || + account.index !== accountIndex || + account.l1Address.toLowerCase() !== this.#boundAddress || + account.accountType !== 0 || + !Array.isArray(account.positions) + ) { + throw new Error('Lighter Scale account state is not authoritative'); + } + const matches = account.positions.filter( + (position) => position.marketId === market.marketId, + ); + if (matches.length > 1) { + throw new Error('Ambiguous Lighter Scale position'); + } + const held = matches[0]; + const maximum = await this.#requireMarketMaxLeverage(params.symbol); + this.#assertSession(generation); + if ( + maximum === null || + (params.leverage !== undefined && params.leverage > maximum) + ) { + throw new Error( + 'Lighter Scale leverage metadata is unavailable or exceeded', + ); + } + if (params.reduceOnly) { + if ( + !held || + (held.sign !== 1 && held.sign !== -1) || + (held.sign === 1) === params.isBuy || + parseFinitePositive(held.position) === null || + new BigNumber(ladder.size).gt(held.position) + ) { + throw new Error( + 'Lighter Scale reduce-only size or side exceeds the live position', + ); + } + const token = + readToken ?? + (await this.#getRecoveryReadToken(accountIndex, generation)).token; + const { reserved } = await this.#readScaleReservations( + accountIndex, + market.marketId, + params.isBuy, + token, + generation, + ); + if (reserved.plus(ladder.size).gt(held.position)) { + throw new Error( + 'Lighter Scale aggregate reduce-only reservation exceeds live position', + ); + } + } else { + const leverage = + params.leverage ?? + scaleLeverageFromMargin( + held?.initialMarginFraction, + this.#marginBySymbol.get(params.symbol)?.defaultInitial, + ); + if ( + !Number.isFinite(leverage) || + leverage < 1 || + leverage > maximum || + parseStrictDecimal(account.availableBalance) === null || + new BigNumber(account.availableBalance).lt(0) + ) { + throw new Error('Invalid Lighter Scale margin state'); + } + if ( + new BigNumber(ladder.notional) + .div(leverage) + .gt(account.availableBalance) + ) { + throw new Error('Insufficient aggregate Lighter Scale margin'); + } + } + return { market, ladder, accountIndex }; }; - /** - * 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. + /** Revalidate the unsubmitted ladder against fresh, scoped venue state. + * @param group - Durable ladder intent. + * @param requestedLeverage - Explicit leverage, when supplied. + * @param token - Account-bound read token. + * @param generation - Source session generation. + * @param pendingClientId - Child being guarded before transport. */ - readonly #recoverTpslSymbol = async ( - symbol: string, - settlementKey: string, + readonly #assertScaleRemaining = async ( + group: LighterScaleGroup, + requestedLeverage: number | undefined, + token: string, generation: number, - accountIndex: number, - ): Promise => { - const markets = await this.#ensureMarkets(); - const market = markets.get(symbol); - if (!market) { - return false; + pendingClientId: number, + ): Promise => { + // Refresh prior children before reading the position: filled reduce-only + // children may have left active orders and already reduced the position. + if ( + group.reduceOnly && + group.rungs.some( + (rung) => rung.state === 'resting' || rung.state === 'accepted', + ) + ) { + await this.#refreshScaleGroup( + group, + this.#scaleKey(group.accountIndex), + token, + generation, + ); } - await this.#ensureSignerReady(); + const maximum = await this.#requireMarketMaxLeverage(group.symbol); this.#assertSession(generation); - const authToken = await this.#getAuthToken(); + if ( + maximum === null || + (requestedLeverage !== undefined && requestedLeverage > maximum) + ) { + throw new Error( + 'Lighter Scale leverage metadata is unavailable or exceeded', + ); + } + const response = await this.#clientService.getAccountByIndex( + group.accountIndex, + ); 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 account = response.accounts?.[0]; + if ( + !Array.isArray(response.accounts) || + response.accounts.length !== 1 || + !account || + account.index !== group.accountIndex || + account.l1Address.toLowerCase() !== group.walletAddress || + group.walletAddress !== this.#boundAddress || + group.network !== (this.#isTestnet ? 'testnet' : 'mainnet') || + account.accountType !== 0 || + !Array.isArray(account.positions) + ) { + throw new Error('Lighter Scale account changed before dispatch'); + } + const positions = account.positions.filter( + (position) => position.marketId === group.marketId, + ); + if (positions.length > 1) { + throw new Error('Ambiguous Lighter Scale position'); + } + const held = positions[0]; + const remaining = group.rungs.filter( + (rung) => + rung.state === 'prepared' || rung.clientOrderId === pendingClientId, + ); + if (group.reduceOnly) { + if ( + !held || + (held.sign !== 1 && held.sign !== -1) || + (held.sign === 1) === group.isBuy || + parseFinitePositive(held.position) === null + ) { + throw new Error( + 'Lighter Scale reduce-only position changed before dispatch', + ); + } + const { reserved, identities } = await this.#readScaleReservations( + group.accountIndex, + group.marketId, + group.isBuy, + token, + generation, + ); + for (const rung of group.rungs) { + if (rung.clientOrderId === pendingClientId) { + continue; } - const readActiveRaw = async (): Promise => { - this.#assertSession(generation); - const response = await this.#clientService.getActiveOrders( - accountIndex, - authToken, + if ( + rung.state === 'accepted' || + rung.state === 'unknown' || + rung.state === 'submitted' || + (rung.state === 'resting' && + (rung.orderId === undefined || !identities.has(rung.orderId))) + ) { + throw new Error( + 'Lighter Scale previous child reservation is unresolved', ); - this.#assertSession(generation); - return response.orders; - }; - const readInactiveFor = this.#makeInactiveReader( - accountIndex, - authToken, - generation, - market.marketId, + } + } + const unsubmitted = remaining.reduce( + (total, rung) => total.plus(rung.size), + new BigNumber(0), + ); + if (reserved.plus(unsubmitted).gt(held.position)) { + throw new Error( + 'Lighter Scale aggregate reduce-only reservation exceeds live position', ); - return await this.#settleTpslObligation({ - settlementKey, - symbol, - journalEntry, - market, - accountIndex, - authToken, - generation, - readActiveRaw, - readInactiveFor, - nextNonce, - submit, - }); - }, - generation, + } + return; + } + const leverage = + requestedLeverage ?? + scaleLeverageFromMargin( + held?.initialMarginFraction, + this.#marginBySymbol.get(group.symbol)?.defaultInitial, + ); + const balance = parseStrictDecimal(account.availableBalance); + if ( + maximum === null || + !Number.isFinite(leverage) || + leverage < 1 || + leverage > maximum || + balance === null || + balance < 0 + ) { + throw new Error('Invalid Lighter Scale remaining margin'); + } + const notional = remaining.reduce( + (total, rung) => total.plus(new BigNumber(rung.price).times(rung.size)), + new BigNumber(0), ); + if (notional.div(leverage).gt(account.availableBalance)) { + throw new Error('Insufficient aggregate Lighter Scale remaining margin'); + } }; - /** - * 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. + /** List account-owned Scale groups without replay or signer setup. + * @returns Durable groups including wholly unknown placement. */ - 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), + async getScaleOrderGroups(): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + await this.#signerReadyPromise?.catch(() => undefined); + this.#assertSession(generation); + const inventory = await this.#resolveRecoveryInventory(generation); + const groups = await Promise.all( + inventory.accounts.map(async (account) => + this.#readScaleGroups(this.#scaleKey(account), account), + ), ); + this.#assertSession(generation); + return groups.flat().map(toLighterScaleGroup); + } + + readonly #settleObservedScaleAcceptance = async ( + group: LighterScaleGroup, + observed: LighterScaleRung[], + generation: number, + ): Promise => { + for (const rung of observed) { + if ( + rung.txHash === null || + rung.nonce === null || + rung.expiresAt === null + ) { + continue; + } + const transaction = await this.#clientService.getTx(rung.txHash); + this.#assertSession(generation); + if (transaction !== null) { + continue; + } + const nonce = await this.#clientService.getNextNonce( + group.accountIndex, + group.apiKeyIndex, + ); + this.#assertSession(generation); + if (!Number.isSafeInteger(nonce.nonce) || nonce.nonce <= rung.nonce) { + continue; + } + await this.#withLedgerLock( + group.accountIndex, + async () => { + const doc = await this.#readNonceLedger( + group.accountIndex, + group.apiKeyIndex, + ); + this.#assertSession(generation); + const entry = doc.entries.find( + (candidate) => + candidate.owner === null && + candidate.intent === + `placeScale:${group.groupId}:${rung.clientOrderId}` && + candidate.kind === LIGHTER_TX_TYPE_CREATE_ORDER && + candidate.nonce === rung.nonce && + candidate.txHash === rung.txHash && + candidate.expiresAt === rung.expiresAt, + ); + if (!entry) { + return; + } + this.#appendRecoveredDispatch(doc, { + recoveryId: `${entry.nonce}:${entry.txHash}`, + kind: entry.kind, + intent: entry.intent, + txHash: entry.txHash, + outcome: rung.state === 'rejected' ? 'failed' : 'succeeded', + evidence: 'fresh-exact-scale-child', + }); + doc.consumedFloor = Math.max(doc.consumedFloor, entry.nonce + 1); + doc.entries = doc.entries.filter((candidate) => candidate !== entry); + await this.#writeNonceLedger( + group.accountIndex, + doc, + group.apiKeyIndex, + ); + this.#assertSession(generation); + const reservationKey = `${group.accountIndex}:${group.apiKeyIndex}`; + this.#nonceReservations.set( + reservationKey, + Math.max( + this.#nonceReservations.get(reservationKey) ?? 0, + entry.nonce + 1, + ), + ); + }, + group.apiKeyIndex, + ); + } }; - /** - * 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, + readonly #refreshScaleGroup = async ( + group: LighterScaleGroup, + key: string, + token: string, + generation: number, + abandon = false, + settleLedger = false, + ): Promise => { + const pending = settleLedger + ? (await this.#readNonceLedger(group.accountIndex, group.apiKeyIndex)) + .entries + : []; + this.#assertSession(generation); + if ( + isLighterScaleTerminal(group) && + !pending.some((entry) => + entry.intent.startsWith(`placeScale:${group.groupId}:`), + ) + ) { + return; + } + const observed: LighterScaleRung[] = []; + const active = await this.#clientService.getActiveOrders( + group.accountIndex, + token, + group.marketId, + ); + this.#assertSession(generation); + if ( + !Array.isArray(active.orders) || + active.orders.some( + (row) => + row.ownerAccountIndex !== group.accountIndex || + row.marketIndex !== group.marketId, + ) + ) { + throw new Error( + 'Lighter Scale active orders are unavailable or unscoped', ); } - 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 rows = [...active.orders]; + const missing = (): boolean => + group.rungs.some( + (rung) => + rung.state !== 'prepared' && + (!['filled', 'canceled', 'rejected'].includes(rung.state) || + pending.some( + (entry) => + entry.intent === + `placeScale:${group.groupId}:${rung.clientOrderId}`, + )) && + !rows.some( + (row) => + row.ownerAccountIndex === group.accountIndex && + row.marketIndex === group.marketId && + row.clientOrderIndex === rung.clientOrderId, + ), + ); + let cursor: string | undefined; + let historyRows = 0; + const cursors = new Set(); + while (missing()) { + const page = await this.#clientService.getInactiveOrders( + group.accountIndex, + token, + LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE, + cursor, + group.marketId, + ); + this.#assertSession(generation); + if ( + !Array.isArray(page.orders) || + page.orders.length > LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE || + (page.orders.length === 0 && page.nextCursor !== undefined) + ) { + throw new Error( + 'Lighter Scale requires bounded authoritative order history', + ); + } + historyRows += page.orders.length; + if (historyRows > LIGHTER_INACTIVE_HISTORY_ROW_LIMIT) { + throw new Error('Lighter Scale order history limit exceeded'); + } + if ( + page.orders.some( + (row) => + row.ownerAccountIndex !== group.accountIndex || + row.marketIndex !== group.marketId, + ) + ) { + throw new Error('Lighter Scale history scope is invalid'); + } + rows.push(...page.orders); + cursor = page.nextCursor; + if (cursor !== undefined) { + if ( + typeof cursor !== 'string' || + cursor.length === 0 || + cursors.has(cursor) + ) { + throw new Error( + 'Lighter Scale history cursor is invalid or repeated', + ); + } + cursors.add(cursor); + } + if (cursor === undefined) { + break; + } + } + for (const rung of group.rungs) { + if (rung.state === 'prepared') { + continue; + } + const matches = rows.filter( + (row) => + row.ownerAccountIndex === group.accountIndex && + row.marketIndex === group.marketId && + row.clientOrderIndex === rung.clientOrderId, + ); + const overlap = matches.length > 1; + if ( + overlap && + (matches.length !== 2 || + !active.orders.includes(matches[0]) || + active.orders.includes(matches[1]) || + String(matches[0].orderIndex) !== String(matches[1].orderIndex)) + ) { + throw new Error('Ambiguous Lighter Scale order identity'); + } + // Validate both snapshots before treating a cross-endpoint overlap as lag. + for (const row of matches) { + const orderId = String(row.orderIndex); + if ( + !/^\d{1,20}$/u.test(orderId) || + (rung.orderId !== undefined && rung.orderId !== orderId) || + row.isAsk !== !group.isBuy || + ![0, 1, false, true].includes(row.reduceOnly) || + Boolean(row.reduceOnly) !== group.reduceOnly || + row.type !== 'limit' || + !new BigNumber(row.price).eq(rung.price) || + !new BigNumber(row.initialBaseAmount).eq(rung.size) + ) { + throw new Error('Lighter Scale order does not match signed intent'); + } + if (rung.state === 'rejected' && row.status !== 'rejected') { + throw new Error('Lighter Scale rejection conflicts with venue order'); + } + if (row.filledBaseAmount !== undefined) { + const filled = parseStrictDecimal(row.filledBaseAmount); + if ( + filled === null || + filled < 0 || + new BigNumber(row.filledBaseAmount).gt(rung.size) + ) { + throw new Error('Invalid Lighter Scale fill quantity'); + } + } + } + if (overlap) { + // Separate endpoint reads are not atomic. Reobserve this exact child + // rather than resolving conflicting mutable snapshots by endpoint order. + continue; + } + const row = matches[0]; + if (row) { + const orderId = String(row.orderIndex); + const adapted = + row.status === 'rejected' + ? undefined + : adaptOrderFromLighter(row, group.symbol); + if ( + row.filledBaseAmount !== undefined && + rung.filledSize !== undefined && + new BigNumber(row.filledBaseAmount).lt(rung.filledSize) + ) { + // Indexer lag cannot erase cumulative execution evidence. + continue; + } + // Limit price is not execution price. No average fill price is inferred. + let nextState: LighterScaleRung['state']; + if (row.status === 'rejected') { + nextState = 'rejected'; + } else if (adapted?.status === 'canceled') { + nextState = 'canceled'; + } else if ( + adapted?.status === 'filled' && + parseStrictDecimal(row.remainingBaseAmount) === 0 + ) { + nextState = 'filled'; + } else if (adapted?.status === 'open') { + nextState = 'resting'; + } else { + nextState = 'accepted'; + } + if ( + ['filled', 'canceled', 'rejected'].includes(rung.state) && + nextState !== rung.state + ) { + // Keep terminal proof when an older active row reappears. Fresh + // matching terminal rows still settle any pending nonce ledger. + continue; + } + delete rung.nonAcceptance; + rung.orderId = orderId; + if (row.filledBaseAmount !== undefined) { + rung.filledSize = row.filledBaseAmount; + } + rung.state = nextState; + observed.push(rung); + } else if (rung.txHash !== null && rung.orderId === undefined) { + const transaction = await this.#clientService.getTx(rung.txHash); + this.#assertSession(generation); + if ( + transaction && + typeof transaction.hash === 'string' && + transaction.hash.replace(/^0x/u, '').toLowerCase() === + rung.txHash.replace(/^0x/u, '').toLowerCase() && + transaction.accountIndex === group.accountIndex && + transaction.apiKeyIndex === group.apiKeyIndex && + transaction.nonce === rung.nonce + ) { + const outcome = getLighterTransactionOutcome(transaction.status); + if (outcome === 'executed') { + delete rung.nonAcceptance; + rung.state = 'accepted'; + } else if (outcome === 'failed') { + rung.nonAcceptance = 'failed'; + rung.state = 'rejected'; + } + } else if ( + abandon && + (rung.state === 'unknown' || rung.state === 'submitted') && + transaction === null && + cursor === undefined && + rung.expiresAt !== null && + Date.now() > rung.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + // Complete fresh absence and the exact signed expiry also cover an + // acknowledged dispatch whose nonce ledger has already retired. + rung.nonAcceptance = 'expired'; + rung.state = 'rejected'; + } + } + } + for (const rung of group.rungs) { + const attempt = rung.cancelAttempt; + if (attempt === undefined || rung.state !== 'resting') { + continue; + } + const transaction = await this.#clientService.getTx(attempt.txHash); + this.#assertSession(generation); + if (transaction !== null) { + const matches = + typeof transaction.hash === 'string' && + transaction.hash.replace(/^0x/u, '').toLowerCase() === + attempt.txHash && + transaction.accountIndex === group.accountIndex && + transaction.apiKeyIndex === attempt.apiKeyIndex && + transaction.nonce === attempt.nonce; + if ( + matches && + getLighterTransactionOutcome(transaction.status) === 'failed' + ) { + delete rung.cancelAttempt; + } else if (matches) { + // A seen transaction cannot become never-landed through later indexer loss. + attempt.acknowledged = true; + } else if (attempt.acknowledged !== true) { + delete attempt.acknowledged; + } + } else if ( + attempt.acknowledged === false && + observed.includes(rung) && + active.orders.some( + (row) => + row.clientOrderIndex === rung.clientOrderId && + String(row.orderIndex) === rung.orderId, + ) && + Date.now() > attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + const nonce = await this.#clientService.getNextNonce( + group.accountIndex, + attempt.apiKeyIndex, + ); + this.#assertSession(generation); + if ( + Number.isSafeInteger(nonce.nonce) && + nonce.nonce >= 0 && + nonce.nonce <= attempt.nonce + ) { + // Exact not-found after validity, an unconsumed nonce and a fresh owned + // resting row prove this unacknowledged cancellation never landed. + delete rung.cancelAttempt; + } + } } - const reconciled = await this.#reconcilePriorTpsl( - readActiveRaw, - readInactiveFor, - accountIndex, - journalEntry, - ); - if (reconciled === 'unresolved') { - return false; + await this.#writeScaleGroup(key, group, generation); + if (settleLedger) { + await this.#settleObservedScaleAcceptance(group, observed, generation); } - 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'; + }; + + readonly #awaitScaleVisibility = async ( + group: LighterScaleGroup, + key: string, + token: string, + generation: number, + settled: () => boolean, + deadline = Date.now() + LIGHTER_SCALE_SETTLEMENT_WINDOW_MS, + ): Promise => { + const startedAt = Date.now(); + const maxPolls = + LIGHTER_SCALE_SETTLEMENT_WINDOW_MS / LIGHTER_SCALE_SETTLEMENT_POLL_MS; + for (let poll = 0; poll <= maxPolls; poll += 1) { + this.#assertSession(generation); + await this.#refreshScaleGroup(group, key, token, generation, true); + if (settled()) { + return; } - 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 now = Date.now(); + if (poll === maxPolls || now < startedAt || now >= deadline) { + return; } - 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, - }, + await new Promise((resolve) => + setTimeout( + resolve, + Math.min(LIGHTER_SCALE_SETTLEMENT_POLL_MS, deadline - now), + ), ); - }; - // 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', + this.#assertSession(generation); + } + }; + + /** Reconcile exact Scale children without signing financial transactions. + * @returns Latest durable observations without replaying placement. + */ + async reviewScaleOrderGroups(): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const key = this.#scaleKey(accountIndex); + return await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}`, + async () => { + const groups = await this.#readScaleGroups(key, accountIndex); + this.#assertSession(generation); + const pending = await Promise.all( + groups.map(async (group) => + ( + await this.#readNonceLedger(accountIndex, group.apiKeyIndex) + ).entries.some((entry) => + entry.intent.startsWith(`placeScale:${group.groupId}:`), + ), + ), + ); + this.#assertSession(generation); + if ( + groups.every(isLighterScaleTerminal) && + pending.every((value) => !value) + ) { + return groups.map(toLighterScaleGroup); + } + const { token } = await this.#getRecoveryReadToken( + accountIndex, + generation, + ); + for (const group of groups) { + // A restart stops the coordinator permanently; it never resumes rungs. + group.placementStopped = true; + await this.#refreshScaleGroup( + group, + key, + token, + generation, + true, + true, + ); + await this.#writeScaleGroup(key, group, generation); + await this.#withLedgerLock( + accountIndex, + async () => { + await this.#resolveNonceLedgerLocked( + accountIndex, + group.apiKeyIndex, + { generation }, + ); + }, + group.apiKeyIndex, + ); + } + this.#assertSession(generation); + return groups.map(toLighterScaleGroup); + }, ); - 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), + } + + readonly #placeScaleOrder = async ( + input: OrderParams, + inheritedGeneration?: number, + ): Promise => { + let params = { ...input }; + let group: LighterScaleGroup | undefined; + let key: string | undefined; + let generation: number | undefined; + let leverageCommitted = false; + try { + params = { + ...params, + expectedScaleLadder: captureExpectedScaleLadder( + params.expectedScaleLadder, ), - ); - const rawInactive = - missingFromActive.length > 0 - ? await readInactiveFor(missingFromActive) - : []; - const stateOf = (clientId: number): 'active' | 'success' | 'failed' => { + }; + this.#ensureSessionBinding(); + generation = inheritedGeneration ?? this.#sessionGeneration; + this.#assertSession(generation); + const prepared = await this.#prepareScaleOrder(params, generation); + const { market, ladder, accountIndex } = prepared; + // Compare metadata only here. Accepted reduce-only children may already + // have reduced the position; remaining exposure is checked separately. + const metadataIntent = ( + currentMarket: LighterOrderBookMeta, + currentLadder: ReturnType, + ): string => + JSON.stringify({ + ladder: currentLadder, + marketId: currentMarket.marketId, + priceDecimals: currentMarket.supportedPriceDecimals, + sizeDecimals: currentMarket.supportedSizeDecimals, + minimumBaseSize: currentMarket.minBaseAmount, + minimumQuoteAmount: currentMarket.minQuoteAmount, + }); + const originalMetadataIntent = metadataIntent(market, ladder); + const metadataGeneration = generation; + const assertFreshMetadata = async (): Promise => { + const markets = await this.#ensureMarkets(true); + this.#assertSession(metadataGeneration); + const currentMarket = markets.get(params.symbol); + try { + if (!currentMarket) { + throw new Error('Unknown Lighter Scale market'); + } + const currentLadder = buildLighterScaleLadder(params, currentMarket); + assertExpectedScaleLadder( + params.expectedScaleLadder, + currentLadder, + currentMarket, + ); + if ( + metadataIntent(currentMarket, currentLadder) !== + originalMetadataIntent + ) { + throw new Error('Lighter Scale grid changed before dispatch'); + } + } catch (error) { + if (params.expectedScaleLadder !== undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE); + } + throw error; + } + }; + + key = this.#scaleKey(accountIndex); + const prior = await this.#readScaleGroups(key, accountIndex); + this.#assertSession(generation); if ( - rawActive.some( - (order) => String(order.clientOrderIndex) === String(clientId), + prior.some( + (entry) => + entry.rungs.some( + (rung) => rung.state === 'unknown' || rung.state === 'submitted', + ) || !entry.placementStopped, ) ) { - return 'active'; + throw new Error( + 'Lighter Scale has unresolved placement; review its group before new exposure', + ); } - const terminal = rawInactive.find( - (order) => String(order.clientOrderIndex) === String(clientId), + if ( + prior.length >= LIGHTER_SCALE_MAX_GROUPS && + !prior.some(isLighterScaleTerminal) + ) { + throw new Error('Lighter Scale ownership is full'); + } + const leverage = await this.#resolveLeverageIntent(params); + const marginMode = + leverage === null + ? null + : await this.#resolveMarginModeForSymbol(params.symbol); + this.#assertSession(generation); + await this.#ensureSignerReady(); + this.#assertSession(generation); + const token = await this.#getAuthToken(); + this.#assertSession(generation); + const capturedGeneration = generation; + const capturedKey = key; + return await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + const fresh = await this.#prepareScaleOrder( + params, + capturedGeneration, + token, + ); + if ( + JSON.stringify(fresh.ladder) !== JSON.stringify(ladder) || + fresh.market.marketId !== market.marketId + ) { + throw new Error('Lighter Scale grid changed before dispatch'); + } + const groups = await this.#readScaleGroups(capturedKey, accountIndex); + this.#assertSession(capturedGeneration); + if ( + groups.some( + (entry) => + !entry.placementStopped || + entry.rungs.some( + (rung) => + rung.state === 'unknown' || rung.state === 'submitted', + ), + ) + ) { + throw new Error('Lighter Scale has unresolved placement'); + } + const ids = this.#allocateClientOrderIndexes(ladder.prices.length); + if (this.#boundAddress === null) { + throw new Error('Lighter Scale account binding is unavailable'); + } + const ownedGroup: LighterScaleGroup = { + version: 1, + groupId: `${LIGHTER_SCALE_PREFIX}${capturedKey.slice(LIGHTER_SCALE_JOURNAL_PREFIX.length)}:${ids[0]}`, + symbol: params.symbol, + accountIndex, + apiKeyIndex: this.#apiKeyIndex, + marketId: market.marketId, + isBuy: params.isBuy, + reduceOnly: params.reduceOnly ?? false, + createdAt: Date.now(), + walletAddress: this.#boundAddress, + network: this.#isTestnet ? 'testnet' : 'mainnet', + sizeDecimals: market.supportedSizeDecimals, + priceDecimals: market.supportedPriceDecimals, + placementStopped: false, + rungs: ladder.prices.map((price, index) => ({ + clientOrderId: ids[index], + price, + size: ladder.sizes[index], + priceInt: new BigNumber(price) + .shiftedBy(market.supportedPriceDecimals) + .toNumber(), + sizeInt: new BigNumber(ladder.sizes[index]) + .shiftedBy(market.supportedSizeDecimals) + .toNumber(), + state: 'prepared', + nonce: null, + txHash: null, + expiresAt: null, + orderExpiry: null, + })), + }; + group = ownedGroup; + await this.#writeScaleGroup( + capturedKey, + ownedGroup, + capturedGeneration, + ); + await this.#assertScaleRemaining( + ownedGroup, + params.leverage, + token, + capturedGeneration, + ownedGroup.rungs[0].clientOrderId, + ); + if (leverage !== null && marginMode !== null) { + const nonce = await nextNonce(); + await assertFreshMetadata(); + const signed = await this.#getSignerBridge().execute({ + function: '_signUpdateLeverage', + params: [ + accountIndex, + market.marketId, + leverage, + marginMode, + nonce, + ], + }); + if (signed.error) { + throw new Error(signed.error); + } + await submit( + LIGHTER_TX_TYPE_UPDATE_LEVERAGE, + signed.txInfo, + () => { + leverageCommitted = true; + }, + { + ...extractDispatchIdentity(signed), + intent: `updateLeverage:${params.symbol}:${String(params.leverage)}`, + beforeDispatch: assertFreshMetadata, + }, + ); + } + for (const rung of ownedGroup.rungs) { + this.#assertSession(capturedGeneration); + // Recheck aggregate remaining exposure after each accepted child. + const nonce = await nextNonce(); + await assertFreshMetadata(); + const signed = await this.#getSignerBridge().execute({ + function: '_signCreateOrder', + params: [ + accountIndex, + market.marketId, + rung.clientOrderId, + String(rung.sizeInt), + String(rung.priceInt), + params.isBuy ? 0 : 1, + LIGHTER_ORDER_TYPE_LIMIT, + LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME, + params.reduceOnly ? 1 : 0, + String(LIGHTER_NO_TRIGGER_PRICE), + LIGHTER_ORDER_EXPIRY_NONE, + nonce, + ], + }); + this.#assertSession(capturedGeneration); + if (signed.error) { + throw new Error(signed.error); + } + const identity = extractDispatchIdentity(signed); + if (identity.txHash === null || identity.expiresAt === null) { + throw new Error( + 'Lighter Scale signing omitted dispatch identity', + ); + } + const wire: unknown = JSON.parse(signed.txInfo); + if ( + typeof wire !== 'object' || + wire === null || + !('Nonce' in wire) || + wire.Nonce !== nonce || + !('OrderExpiry' in wire) || + typeof wire.OrderExpiry !== 'number' || + !Number.isSafeInteger(wire.OrderExpiry) || + wire.OrderExpiry <= Date.now() + ) { + throw new Error( + 'Invalid Lighter Scale signed nonce or order expiry', + ); + } + rung.nonce = nonce; + rung.txHash = identity.txHash; + rung.expiresAt = identity.expiresAt; + rung.orderExpiry = wire.OrderExpiry; + await submit( + LIGHTER_TX_TYPE_CREATE_ORDER, + signed.txInfo, + () => { + rung.state = 'submitted'; + }, + { + ...identity, + intent: `placeScale:${ownedGroup.groupId}:${rung.clientOrderId}`, + beforeDispatch: async () => { + rung.state = 'unknown'; + await this.#writeScaleGroup( + capturedKey, + ownedGroup, + capturedGeneration, + ); + await this.#assertScaleRemaining( + ownedGroup, + params.leverage, + token, + capturedGeneration, + rung.clientOrderId, + ); + await assertFreshMetadata(); + }, + onNotDispatched: async () => { + rung.state = 'prepared'; + rung.nonce = null; + rung.txHash = null; + rung.expiresAt = null; + rung.orderExpiry = null; + await this.#writeScaleGroup( + capturedKey, + ownedGroup, + capturedGeneration, + ); + }, + }, + ); + await this.#writeScaleGroup( + capturedKey, + ownedGroup, + capturedGeneration, + ); + await this.#awaitScaleVisibility( + ownedGroup, + capturedKey, + token, + capturedGeneration, + () => !['unknown', 'submitted'].includes(rung.state), + ); + if ( + rung.state === 'unknown' || + rung.state === 'submitted' || + rung.state === 'rejected' + ) { + throw new Error( + 'Lighter Scale child acceptance is not confirmed; remaining rungs stopped', + ); + } + } + ownedGroup.placementStopped = true; + await this.#writeScaleGroup( + capturedKey, + ownedGroup, + capturedGeneration, + ); + return { ...toLighterScaleGroup(ownedGroup), success: true }; + }, + capturedGeneration, + ); + } catch (error) { + if (group && key !== undefined && generation !== undefined) { + group.placementStopped = true; + try { + await this.#writeScaleGroup(key, group, generation); + } catch { + /* Prior durable dispatch ownership remains. */ + } + } + return { + ...(group ? toLighterScaleGroup(group) : {}), + success: false, + error: ensureError(error, 'LighterProvider.placeScaleOrder').message, + ...(leverageCommitted + ? { partialState: { leverageUpdated: Number(params.leverage) } } + : {}), + }; + } + }; + + readonly #cancelScaleOrder = async ( + params: CancelOrderParams, + generation: number, + ): Promise => { + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const key = this.#scaleKey(accountIndex); + const lookup = async (): Promise => { + const groups = await this.#readScaleGroups(key, accountIndex); + this.#assertSession(generation); + const group = groups.find( + (entry) => + entry.groupId === params.orderId && entry.symbol === params.symbol, ); - if (!terminal) { - // Reconcile proved never-landed: same outcome as failed. - return 'failed'; + if (!group) { + throw new Error('Unknown Lighter Scale group for this account'); } - 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); + return 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 terminal = await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}`, + async () => { + const current = await lookup(); + current.placementStopped = true; + if (!isLighterScaleTerminal(current)) { + try { + const { token: readToken } = await this.#getRecoveryReadToken( + accountIndex, + generation, + ); + await this.#refreshScaleGroup( + current, + key, + readToken, + generation, + true, + ); + } catch (error) { + if (!(error instanceof LighterRecoveryReadAuthorityError)) { + throw error; + } + // A live owned group can acquire signing authority below even + // after local key loss. Registration and transport errors cannot. + this.#assertSession(generation); + } } - const activeLegs = attempt.clientIds.filter( - (clientId) => stateOf(clientId) === 'active', - ); - if (activeLegs.length > 0) { - pushCreatedGroup(attempt.clientIds); + await this.#writeScaleGroup(key, current, generation); + if (!isLighterScaleTerminal(current)) { + return false; } - } - for (const prior of journalEntry.priorTriggers) { - if (priorActive(prior)) { - await submitRecoveryCancel(prior.orderId, 'stale'); - cancelledOrderIds.push(prior.orderId); + await this.#withLedgerLock( + accountIndex, + async () => { + await this.#resolveNonceLedgerLocked( + accountIndex, + current.apiKeyIndex, + { generation }, + ); + }, + current.apiKeyIndex, + ); + return true; + }, + ); + this.#assertSession(generation); + if (terminal) { + return { success: true, orderId: params.orderId, providerId: 'lighter' }; + } + await this.#ensureSignerReady(); + const token = await this.#getAuthToken(); + this.#assertSession(generation); + return await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + const group = await lookup(); + group.placementStopped = true; + await this.#writeScaleGroup(key, group, generation); + await this.#refreshScaleGroup(group, key, token, generation, true); + // One group deadline includes every authorized child cancellation. + // Dispatch each exact child once before polling the whole group. + const settlementDeadline = + Date.now() + LIGHTER_SCALE_SETTLEMENT_WINDOW_MS; + const cancellations: LighterScaleRung[] = []; + for (const rung of group.rungs) { + if ( + ['prepared', 'filled', 'canceled', 'rejected'].includes(rung.state) + ) { + continue; + } + if (rung.state !== 'resting' || rung.orderId === undefined) { + continue; + } + if (rung.cancelAttempt !== undefined) { + cancellations.push(rung); + continue; + } + const nonce = await nextNonce(); + const signed = await this.#getSignerBridge().execute({ + function: '_signCancelOrder', + params: [accountIndex, group.marketId, rung.orderId, nonce], + }); + if (signed.error) { + throw new Error(signed.error); + } + const identity = extractDispatchIdentity(signed); + const wire: unknown = JSON.parse(signed.txInfo); + if ( + identity.txHash === null || + identity.expiresAt === null || + typeof wire !== 'object' || + wire === null || + !('Nonce' in wire) || + wire.Nonce !== nonce + ) { + throw new Error('Invalid Lighter Scale cancellation identity'); + } + const attempt: NonNullable = { + apiKeyIndex: this.#apiKeyIndex, + nonce, + txHash: identity.txHash.replace(/^0x/u, '').toLowerCase(), + expiresAt: identity.expiresAt, + }; + let accepted = false; + try { + await submit( + LIGHTER_TX_TYPE_CANCEL_ORDER, + signed.txInfo, + () => { + accepted = true; + attempt.acknowledged = true; + }, + { + ...identity, + intent: `cancelScale:${group.groupId}:${rung.clientOrderId}`, + beforeDispatch: async () => { + rung.cancelAttempt = attempt; + await this.#writeScaleGroup(key, group, generation); + }, + onNotDispatched: async () => { + delete rung.cancelAttempt; + await this.#writeScaleGroup(key, group, generation); + }, + afterAccepted: async () => + this.#recordScaleCancellationAcknowledgement( + key, + group, + rung, + attempt, + true, + ), + }, + ); + } catch (error) { + if (!accepted) { + await this.#recordScaleCancellationAcknowledgement( + key, + group, + rung, + attempt, + false, + ); + } + throw error; + } + cancellations.push(rung); } - } - }; - const rollbackActiveJournalledLegs = async ( - legIds: number[], - ): Promise => { - for (const clientId of legIds) { - if (stateOf(clientId) !== 'active') { - continue; + if (cancellations.length > 0) { + await this.#awaitScaleVisibility( + group, + key, + token, + generation, + () => cancellations.every((rung) => rung.state !== 'resting'), + settlementDeadline, + ); } - const survivor = rawActive.find( - (order) => String(order.clientOrderIndex) === String(clientId), - ); - if (survivor) { - await submitRecoveryCancel(String(survivor.orderIndex), 'rollback'); - cancelledOrderIds.push(String(survivor.orderIndex)); + await this.#refreshScaleGroup(group, key, token, generation, true); + if (!isLighterScaleTerminal(group)) { + throw new Error('Lighter Scale cancellation remains unresolved'); } + await this.#writeScaleGroup(key, group, generation); + return { success: true, orderId: group.groupId, providerId: 'lighter' }; + }, + generation, + ); + }; + + async getMarkets(_params?: GetMarketsParams): Promise { + try { + const markets = await this.#clientService.getOrderBooks(); + await this.#ensureMarketMargins(); + return markets + .filter((market) => market.marketType === 'perp') + .flatMap((market) => { + const margins = this.#marginBySymbol.get(market.symbol); + if (!margins) { + // #ensureMarketMargins deliberately omits inactive rows whose + // retired risk metadata cannot produce a canonical MarketInfo. + if (market.status === 'inactive') { + return []; + } + throw new Error( + `${LIGHTER_DATA_INTEGRITY_PREFIX} missing authoritative leverage for ${market.symbol}`, + ); + } + const adapted = adaptMarketFromLighter( + market, + deriveLighterMaxLeverage(margins.minInitial, market.symbol), + ); + // minBaseAmount and minQuoteAmount are maker-only. Market and + // IOC orders can use one base-size tick, so the public universal + // minimum must report that tick rather than the maker floor. + delete adapted.minimumOrderSize; + if (margins?.lastTradePrice && margins.lastTradePrice > 0) { + const oneTickUsd = + margins.lastTradePrice / 10 ** market.supportedSizeDecimals; + if (Number.isFinite(oneTickUsd) && oneTickUsd > 0) { + adapted.minimumOrderSize = oneTickUsd; + } + } + return [adapted]; + }); + } catch (caughtError) { + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getMarkets', + ); + this.#deps.debugLogger.log('[LighterProvider] getMarkets failed', { + error: String(wrappedError), + ...this.#getErrorContext('getMarkets'), + }); + if ( + this.#isUnsupportedCapabilityError(caughtError) || + this.#isDataIntegrityError(caughtError) + ) { + throw caughtError; } - }; - 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') + return []; + } + } + + async getMarketDataWithPrices(): Promise { + try { + const response = await this.#clientService.getOrderBookDetails(); + return response.orderBookDetails + .filter((detail) => detail.marketType === 'perp') + .filter((detail) => !isInactiveMarketWithoutUsableRiskMetadata(detail)) + .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'), + }, + ); + if ( + this.#isUnsupportedCapabilityError(caughtError) || + this.#isDataIntegrityError(caughtError) + ) { + throw caughtError; + } + return []; + } + } + + // ============================================================================ + // Account Operations + // ============================================================================ + + async getPositions(_params?: GetPositionsParams): Promise { + return this.#getPositions(false); + } + + /** + * Keep raw signed decimals only for exact partial protection intent. + * + * @param preserveRawSize - Preserve venue size before adapter number conversion. + * @returns Validated positions. + */ + readonly #getPositions = async ( + preserveRawSize: boolean, + ): Promise => { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + // Risk metadata is required for every returned position. A transport + // failure must reject instead of looking like an authoritative empty + // account. + await this.#ensureMarketMargins(); + try { + 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) => { + const adapted = adaptPositionFromLighter( + position, + this.#maxLeverageForPosition(position), + ); + return preserveRawSize + ? { + ...adapted, + size: `${position.sign === -1 ? '-' : ''}${position.position}`, + } + : adapted; + }) + .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 targetGone = !rawActive.some( - (order) => String(order.orderIndex) === attempt.orderId, + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getPositions', ); - const provenNeverLanded = - attempt.outcome === 'unknown' && - compactionNow > attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS; - // Accepted-but-terminal-FAILED cancels (venue status 0) landed - // without mutating the books: proven-resolved, compactable. - const landedTerminalFailed = - getLighterTransactionOutcome(attempt.terminalStatus) === 'failed'; - 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] getPositions failed', { + error: String(wrappedError), + ...this.#getErrorContext('getPositions'), }); + return []; + } + }; + + /** + * Report the margin mode Lighter currently binds to a market. Only an + * open position locks the mode here: the venue refuses a mode change + * while a position is open. Resting orders are not treated as a lock. + * + * @param params - Market and optional provider route. + * @returns The current lock, or unavailable when it cannot be read. + */ + async getMarginModeLock( + params: GetMarginModeLockParams, + ): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const wireMarginMode = await this.#readPositionMarginMode(params.symbol); + this.#assertSession(generation); + if (wireMarginMode === null) { + return { status: 'unlocked', providerId: this.protocolId }; + } + return { + status: 'locked', + providerId: this.protocolId, + marginMode: + wireMarginMode === LIGHTER_MARGIN_MODE_ISOLATED + ? 'isolated' + : 'cross', + reason: 'position', + }; + } catch (error) { 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)', + '[LighterProvider] getMarginModeLock unavailable', + { + symbol: params.symbol, + error: ensureError(error, 'LighterProvider.getMarginModeLock') + .message, + }, ); + return { + status: 'unavailable', + providerId: this.protocolId, + reason: 'provider_unavailable', + }; } - 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); - } + } + + 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; } - } 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(); - } + return adaptAccountStateFromLighter(account); + } 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; } - } 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', - ); + const wrappedError = ensureError( + caughtError, + 'LighterProvider.getAccountState', + ); + this.#deps.debugLogger.log('[LighterProvider] getAccountState failed', { + error: String(wrappedError), + ...this.#getErrorContext('getAccountState'), + }); + return EMPTY_ACCOUNT_STATE; + } + } + + /** Decorate real venue rows only when they match durable Scale intent. + * @param rows - Authoritative venue rows. + * @param accountIndex - Bound account. + * @param generation - Issuing session. + * @returns Adapted orders, with exact durable group ownership when present. + */ + readonly #adaptScaleOrders = async ( + rows: LighterApiOrder[], + accountIndex: number, + generation: number, + ): Promise => { + let groups: LighterScaleGroup[] = []; + try { + groups = await this.#readScaleGroups( + this.#scaleKey(accountIndex), + accountIndex, + ); + } catch { + // Attribution is optional; group inventory still exposes integrity errors. + } + this.#assertSession(generation); + return rows.map((row) => { + const order = adaptOrderFromLighter( + row, + this.#marketsById.get(row.marketIndex)?.symbol ?? + String(row.marketIndex), + ); + const matches = groups.filter( + (group) => + group.accountIndex === row.ownerAccountIndex && + group.marketId === row.marketIndex && + group.rungs.some( + (rung) => + rung.clientOrderId === row.clientOrderIndex && + rung.state !== 'rejected' && + (rung.orderId === undefined || + rung.orderId === String(row.orderIndex)) && + new BigNumber(rung.size).eq(row.initialBaseAmount) && + new BigNumber(rung.price).eq(row.price), + ) && + row.isAsk === !group.isBuy && + Boolean(row.reduceOnly) === group.reduceOnly && + row.type === 'limit', + ); + if (matches.length > 1) { + throw new Error('Ambiguous Lighter Scale ownership'); + } + return matches.length === 1 + ? { ...order, strategyGroupId: matches[0].groupId } + : order; + }); + }; + + /** + * 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 this.#adaptScaleOrders(response.orders, accountIndex, generation); + }; + + 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) || + this.#isDataIntegrityError(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 []; } - 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 }, + } + + 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 = await this.#adaptScaleOrders( + response.orders ?? [], + accountIndex, + generation, ); - // 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', - ); + // 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) || + this.#isDataIntegrityError(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 []; } - // A refused clear (superseded by a newer operation) is UNRESOLVED — - // never reported as success. - return await this.#clearTpslJournal( - settlementKey, - journalEntry.operationId, + } + + async getCurrentAccountId(): Promise { + const address = this.#walletService.getUserAddress(); + const chainId = getLighterChainId(this.#clientService.network); + return `eip155:${chainId}:${address}`; + } + + // ============================================================================ + // 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); }; /** - * 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. + * Account-wide attached ownership survives signer-key migration. * - * @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. + * @param accountIndex - Captured venue account. + * @returns Storage identity bound to this wallet and network. */ - 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) { + readonly #attachedKey = (accountIndex: number): string => + `lighterAttachedOrders:${this.#isTestnet ? 'testnet' : 'mainnet'}:${this.#boundAddress}:${accountIndex}`; + + /** + * Read immutable groups and reserve their client IDs in this provider lifetime. + * + * @param key - Captured account storage key. + * @returns Validated durable groups. + */ + readonly #readAttachedGroups = async ( + key: string, + ): Promise => { + const groups = parseLighterAttachedGroups( + await this.#deps.diskCache.getItem(key), + ); + const scope = `${LIGHTER_ATTACHED_HANDLE_PREFIX}${key.slice('lighterAttachedOrders:'.length)}:`; + for (const group of groups) { if ( - attempt.outcome === 'accepted' && - satisfiedOnBooks(attempt, rawActive, rawInactive) + group.accountIndex !== Number(key.split(':').at(-1)) || + !group.groupId.startsWith(scope) || + !group.groupId.endsWith(`:${group.orders[0][0]}:${group.orders[0][1]}`) ) { - 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 status (0) 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'; - } - const transactionOutcome = getLighterTransactionOutcome( - lookedUp.status, + throw new Error( + 'Lighter attached-order journal belongs to a different account', ); - if (transactionOutcome === 'failed') { - // 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; - } - // Executed proves the transaction landed, but the order books may - // still lag. Keep the journal until the expected create/cancel state - // is visible. - 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'; + for (const order of group.orders) { + this.#issuedClientOrderIds.add(order[1]); } - // 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 groups; + }; + + /** + * Persist one group without replacing another concurrent operation. + * + * @param key - Captured account storage key. + * @param group - Group with immutable intent and updated observation. + * @param generation - Caller session. + */ + readonly #writeAttachedGroup = async ( + key: string, + group: LighterAttachedGroup, + generation: number, + ): Promise => { + await withStorageMutex(key, async () => { + this.#assertSession(generation); + const groups = await this.#readAttachedGroups(key); + this.#assertSession(generation); + const index = groups.findIndex( + (entry) => entry.groupId === group.groupId, ); - } - return 'resolved'; + if (index < 0) { + if (groups.length >= LIGHTER_ATTACHED_MAX_GROUPS) { + const reclaimable = groups.findIndex( + (entry) => + entry.submission === 'canceled' || + entry.submission === 'completed', + ); + if (reclaimable < 0) { + throw new Error('Lighter attached-order ownership is full'); + } + groups.splice(reclaimable, 1); + } + groups.push(group); + } else { + if (JSON.stringify(groups[index]) === JSON.stringify(group)) { + return; + } + groups[index] = group; + } + const serialized = JSON.stringify(groups); + parseLighterAttachedGroups(serialized); + await this.#deps.diskCache.setItem(key, serialized); + this.#assertSession(generation); + }); }; /** - * 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. + * Read local attached group identities without signer setup or financial replay. * - * @param accountIndex - Venue account index. - * @param nonce - The proven-unconsumed nonce. + * @returns All retained groups for the selected account. */ - readonly #releaseNonceReservation = ( - accountIndex: number, - nonce: number, - ): void => { - const reservationKey = `${accountIndex}:${this.#apiKeyIndex}`; - if (this.#nonceReservations.get(reservationKey) === nonce + 1) { - this.#nonceReservations.set(reservationKey, nonce); - } - }; + async getAttachedOrderGroups(): Promise { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + await this.#signerReadyPromise?.catch(() => undefined); + this.#assertSession(generation); + const inventory = await this.#resolveRecoveryInventory(generation); + const groups = await Promise.all( + inventory.accounts.map(async (accountIndex) => + this.#readAttachedGroups(this.#attachedKey(accountIndex)), + ), + ); + this.#assertSession(generation); + return groups.flat().map(toAttachedOrderGroup); + } /** - * 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. + * Read one bounded market snapshot under the caller's session. * - * @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. + * @param accountIndex - Venue account. + * @param token - Registered read authority. + * @param marketIndex - Signed market identity. + * @param generation - Caller session. + * @param groups - Same-market groups whose missing legs require older history. + * @returns Authoritative active and bounded inactive rows. */ - 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), - ), + readonly #readAttachedOrders = async ( + accountIndex: number, + token: string, + marketIndex: number, + generation: number, + groups: LighterAttachedGroup[], + ): Promise => { + const active = await this.#clientService.getActiveOrders( + accountIndex, + token, + marketIndex, + ); + this.#assertSession(generation); + if (!Array.isArray(active.orders)) { + throw new Error( + 'Lighter attached orders require bounded authoritative order containers', ); - 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) => { + } + const rows = [...active.orders]; + let cursor: string | undefined; + const seenCursors = new Set(); + let historyRows = 0; + let pages = 0; + const prepared = groups.map((group) => group.preparedAt); + const cutoff = prepared.every((time): time is number => time !== undefined) + ? (Math.min(...prepared) - LIGHTER_ATTACHED_HISTORY_TIME_SLACK_MS) / 1000 + : null; + let previousTimestamp = Number.POSITIVE_INFINITY; + let orderedHistory = true; + do { + const inactive = await this.#clientService.getInactiveOrders( + accountIndex, + token, + LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE, + cursor, + marketIndex, + ); + this.#assertSession(generation); + pages += 1; + if ( + !Array.isArray(inactive.orders) || + inactive.orders.length > LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE || + (inactive.nextCursor !== undefined && + typeof inactive.nextCursor !== 'string') + ) { + throw new Error( + 'Lighter attached orders require bounded authoritative order containers', + ); + } + historyRows += inactive.orders.length; + rows.push(...inactive.orders); + cursor = inactive.nextCursor; + for (const row of inactive.orders) { if ( - rawActive.some( - (order) => String(order.clientOrderIndex) === String(clientId), - ) + !Number.isFinite(row.timestamp) || + row.timestamp <= 0 || + row.timestamp > previousTimestamp || + row.ownerAccountIndex !== accountIndex || + row.marketIndex !== marketIndex ) { - return { clientId, state: 'active' as const }; - } - const terminal = rawInactive.find( - (order) => String(order.clientOrderIndex) === String(clientId), - ); - if (!terminal) { - return { clientId, state: 'missing' as const }; + orderedHistory = false; } - 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', + previousTimestamp = row.timestamp; + } + if ( + !groups.some((group) => + correlateLighterAttachedOrders(group, rows).some( + (row) => row === null, + ), + ) + ) { + return { rows, complete: true }; + } + if (cutoff !== null && orderedHistory && previousTimestamp < cutoff) { + return { rows, complete: true }; + } + if ( + cursor && + (historyRows >= LIGHTER_INACTIVE_HISTORY_ROW_LIMIT || + pages >= LIGHTER_ATTACHED_HISTORY_PAGE_LIMIT) + ) { + return { rows, complete: false }; + } + if (cursor) { + if (seenCursors.has(cursor) || inactive.orders.length === 0) { + throw new Error( + 'Lighter attached history cursor did not make progress', ); - 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 }; + seenCursors.add(cursor); } - await new Promise((resolve) => - setTimeout(resolve, LIGHTER_TPSL_SETTLE_POLL_MS), - ); - } - return { outcome: 'timeout' }; + } while (cursor); + return { rows, complete: orderedHistory }; }; /** - * 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. + * Transfer fresh complete native-group acceptance to the generic nonce ledger. + * The caller holds the account mutex and has persisted the exact observation. + * Saved venue IDs alone never invoke this handoff. + * @param group - Scoped durable group with its complete fresh observation. + * @param rows - Exact rows from this review, not persisted IDs. + * @param generation - Issuing session. */ - readonly #assertSession = (generation: number): void => { - if (this.#isDisconnected) { - throw new LighterSessionCancelledError( - 'the Lighter provider was disconnected', - ); - } - if (generation !== this.#sessionGeneration) { - throw new LighterSessionCancelledError( - '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 - // 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 LighterSessionCancelledError( - 'no wallet account is bound to the venue session', - ); - } - let address: string | null = null; - try { - address = this.#walletService.getUserAddress().toLowerCase(); - } catch { - address = null; + readonly #settleObservedAttachedAcceptance = async ( + group: LighterAttachedGroup, + rows: (LighterApiOrder | null)[], + generation: number, + ): Promise => { + if ( + rows.length !== group.orders.length || + rows.some((row) => row === null) || + group.txHash === null || + group.nonce === null || + group.expiresAt === null || + group.acceptanceReviewedAt === undefined + ) { + return; } - 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 LighterSessionCancelledError( - 'the wallet switched accounts (or the signer reset) while this operation was in flight', - ); + const transaction = await this.#clientService.getTx(group.txHash); + this.#assertSession(generation); + // Exact transaction outcomes retain their existing generic settlement path. + // Mismatched, pending or failed hash responses never borrow leg evidence. + if (transaction !== null) { + return; } + const nonce = await this.#clientService.getNextNonce( + group.accountIndex, + group.apiKeyIndex, + ); + this.#assertSession(generation); + if (!Number.isSafeInteger(nonce.nonce) || nonce.nonce <= group.nonce) { + return; + } + await this.#withLedgerLock( + group.accountIndex, + async () => { + const doc = await this.#readNonceLedger( + group.accountIndex, + group.apiKeyIndex, + ); + this.#assertSession(generation); + const entry = doc.entries.find( + (candidate) => + candidate.owner === null && + candidate.intent === `placeAttached:${group.groupId}` && + candidate.kind === LIGHTER_TX_TYPE_CREATE_GROUPED_ORDERS && + candidate.nonce === group.nonce && + candidate.txHash === group.txHash && + candidate.expiresAt === group.expiresAt, + ); + if (!entry) { + return; + } + this.#appendRecoveredDispatch(doc, { + recoveryId: `${entry.nonce}:${entry.txHash}`, + kind: entry.kind, + intent: entry.intent, + txHash: entry.txHash, + outcome: 'succeeded', + evidence: 'fresh-exact-attached-legs', + }); + doc.consumedFloor = Math.max(doc.consumedFloor, entry.nonce + 1); + doc.entries = doc.entries.filter((candidate) => candidate !== entry); + await this.#writeNonceLedger( + group.accountIndex, + doc, + group.apiKeyIndex, + ); + this.#assertSession(generation); + const reservationKey = `${group.accountIndex}:${group.apiKeyIndex}`; + this.#nonceReservations.set( + reservationKey, + Math.max( + this.#nonceReservations.get(reservationKey) ?? 0, + entry.nonce + 1, + ), + ); + }, + group.apiKeyIndex, + ); }; - /** 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. + /** + * Check terminal status only on an exactly correlated owned leg. + * + * @param row - Exact venue leg. + * @param symbol - Group symbol. + * @returns Whether the leg cannot execute again. + */ + readonly #isAttachedOrderTerminal = ( + row: LighterApiOrder, + symbol: string, + ): boolean => { + if (row.status === 'rejected') { + return true; + } + const order = adaptOrderFromLighter(row, symbol); + return ( + order.status === 'canceled' || + (order.status === 'filled' && + parseStrictDecimal(row.remainingBaseAmount) === 0) + ); }; /** - * Create the WASM signer client and register the venue key if the - * account's key slot does not hold it yet. Deduplicated. + * Review exact signed identities using registered local read authority. * - * @returns Resolves when the signer session is ready. + * @returns Venue observations; missing rows and linkage remain unknown. */ - readonly #ensureSignerReady = async (): Promise => { + async reviewAttachedOrderGroups(): Promise { this.#ensureSessionBinding(); - this.#assertSession(this.#sessionGeneration); - 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 nonce = await nextNonce(); - this.#assertSession(generation); - const created = await bridge.createClient({ - chainId, - accountIndex, - nonce, - apiKeyIndex: this.#apiKeyIndex, - }); - if (created.error || !created.success) { - throw new Error( - `Lighter signer client creation failed: ${created.error ?? 'unknown'}`, - ); - } + const key = this.#attachedKey(accountIndex); + return await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}`, + async () => { 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}`; - // Re-establishment passes only public session metadata. The client - // owns and restores the venue key inside the bridge. - this.#signerRecreateParams = { chainId, accountIndex }; - bridgeClientOwners.set(this.#rawSignerBridge(), this.#signerIdentity); - - // Register the venue key when the slot does not hold it yet. The - // bridge returns only public registration data; the client-owned - // venue private key never crosses into Core. - const registered = await this.#isVenueKeyRegistered(accountIndex); + const groups = await this.#readAttachedGroups(key); this.#assertSession(generation); - if (!registered) { - await this.#registerVenueKey( + const pendingGroups = new Set(); + for (const group of groups) { + const ledger = await this.#readNonceLedger( accountIndex, - created.body, - generation, - nextNonce, - submit, + group.apiKeyIndex, ); this.#assertSession(generation); + if ( + ledger.entries.some( + (entry) => entry.intent === `placeAttached:${group.groupId}`, + ) + ) { + pendingGroups.add(group.groupId); + } + } + if ( + groups.every( + (group) => + (group.submission === 'canceled' || + group.submission === 'completed') && + !pendingGroups.has(group.groupId), + ) + ) { + return groups.map(toAttachedOrderGroup); + } + const { token } = await this.#getRecoveryReadToken( + accountIndex, + generation, + ); + this.#assertSession(generation); + const results: AttachedOrderGroup[] = []; + const snapshots = new Map(); + for (const group of groups) { + if ( + (group.submission === 'canceled' || + group.submission === 'completed') && + !pendingGroups.has(group.groupId) + ) { + results.push(toAttachedOrderGroup(group)); + continue; + } + const marketIndex = group.orders[0][0]; + let snapshot = snapshots.get(marketIndex); + if (!snapshot) { + snapshot = await this.#readAttachedOrders( + accountIndex, + token, + marketIndex, + generation, + groups.filter( + (candidate) => + candidate.orders[0][0] === marketIndex && + ((candidate.submission !== 'canceled' && + candidate.submission !== 'completed') || + pendingGroups.has(candidate.groupId)), + ), + ); + snapshots.set(marketIndex, snapshot); + } + const rows = correlateLighterAttachedOrders(group, snapshot.rows); + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row) { + group.venueIds[index] = String(row.orderIndex); + } + } + const hasLegs = rows.some((row) => row !== null); + const allLegs = rows.every((row) => row !== null); + if (allLegs) { + delete group.nonAcceptance; + group.submission = 'accepted'; + if (pendingGroups.has(group.groupId)) { + group.acceptanceReviewedAt = Date.now(); + } + } + if (hasLegs && group.nonAcceptance !== undefined) { + delete group.nonAcceptance; + group.submission = 'accepted'; + } + if ( + (group.submission === 'unknown' || + (group.submission === 'accepted' && !hasLegs)) && + group.txHash !== null + ) { + const transaction = await this.#clientService.getTx(group.txHash); + this.#assertSession(generation); + if ( + transaction && + typeof transaction.hash === 'string' && + transaction.hash.toLowerCase().replace(/^0x/u, '') === + group.txHash.toLowerCase().replace(/^0x/u, '') && + transaction.accountIndex === accountIndex && + transaction.apiKeyIndex === group.apiKeyIndex && + transaction.nonce === group.nonce + ) { + const outcome = getLighterTransactionOutcome(transaction.status); + if (outcome === 'executed') { + delete group.nonAcceptance; + group.submission = 'accepted'; + } else if ( + outcome === 'failed' && + !hasLegs && + snapshot.complete && + group.venueIds.every((id) => id === null) + ) { + group.nonAcceptance = 'failed'; + group.submission = 'canceled'; + } + } + } + const parent = rows[0]; + const children = rows.slice(1); + const linked = + parent !== null && + children.every((child) => { + if (child === null) { + return false; + } + const parentIds = [ + String(parent.orderIndex), + parent.orderId, + ].filter((id): id is string => id !== undefined); + const childIds = [String(child.orderIndex), child.orderId].filter( + (id): id is string => id !== undefined, + ); + const childReferences = [ + child.parentOrderIndex === undefined + ? undefined + : String(child.parentOrderIndex), + child.parentOrderId, + ].filter( + (id): id is string => + id !== undefined && id !== '' && id !== '0', + ); + const parentReferences = [ + parent.toTriggerOrderId0, + parent.toTriggerOrderId1, + ].filter( + (id): id is string => + id !== undefined && id !== '' && id !== '0', + ); + return ( + childReferences.every((id) => parentIds.includes(id)) && + (childReferences.length > 0 || + parentReferences.some((id) => childIds.includes(id))) && + (parentReferences.length === 0 || + parentReferences.some((id) => childIds.includes(id))) + ); + }); + const orders: NonNullable = rows.map( + (row, index) => { + if (!row) { + return { + clientOrderId: String(group.orders[index][1]), + status: 'unknown', + }; + } + const status = + row.status === 'rejected' + ? 'rejected' + : adaptOrderFromLighter(row, group.symbol).status; + const filled = + row.filledBaseAmount === undefined + ? null + : parseStrictDecimal(row.filledBaseAmount); + if ( + (row.filledBaseAmount !== undefined && filled === null) || + (filled !== null && (!Number.isFinite(filled) || filled < 0)) + ) { + throw new Error( + 'Lighter attached review has invalid fill quantity', + ); + } + let observedStatus: NonNullable< + AttachedOrderGroup['orders'] + >[number]['status'] = 'unknown'; + if (status === 'open') { + observedStatus = 'resting'; + if (row.status === 'pending') { + observedStatus = 'waiting'; + } else if (filled !== null && filled > 0) { + observedStatus = 'partially-filled'; + } + } else if ( + status === 'filled' || + status === 'canceled' || + status === 'rejected' + ) { + observedStatus = status; + } + return { + clientOrderId: String(row.clientOrderIndex), + orderId: String(row.orderIndex), + status: observedStatus, + ...(filled === null ? {} : { filledSize: String(filled) }), + }; + }, + ); + if ( + rows.every( + (row) => + row !== null && + this.#isAttachedOrderTerminal(row, group.symbol), + ) + ) { + group.submission = 'completed'; + } + await this.#writeAttachedGroup(key, group, generation); + if (allLegs && pendingGroups.has(group.groupId)) { + await this.#settleObservedAttachedAcceptance( + group, + rows, + generation, + ); + } + results.push({ + ...toAttachedOrderGroup(group), + orders, + historyStatus: + snapshot.complete || allLegs ? 'complete' : 'bounded', + linkage: linked ? 'confirmed' : 'unknown', + }); } + this.#assertSession(generation); + return results; }, - 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 => { - // Query all slots. Lighter returns `api key not found` when a missing - // slot is requested directly, which would make first-time registration - // impossible. The all-slots response is successful and represents an - // unused slot by omitting it from `apiKeys`. - const response = await this.#clientService.getApiKeys(accountIndex); - const configuredSlot = response.apiKeys.find( - (key) => key.apiKeyIndex === this.#apiKeyIndex, - ); - if (!configuredSlot) { - return false; - } - const normalizeKey = (key: string | null): string => - (key ?? '').replace(/^0x/u, '').toLowerCase(); - if ( - normalizeKey(configuredSlot.publicKey) === - normalizeKey(this.#venuePublicKey) - ) { - return true; - } - throw new Error( - `Lighter API key slot ${String(this.#apiKeyIndex)} already contains a different key; automatic replacement is disabled because personal_sign output is not guaranteed to be byte-identical across signer implementations or devices`, ); - }; + } - readonly #registerVenueKey = async ( - accountIndex: number, - changePubKeyBody: string, + /** + * Cancel a recorded native group using exact venue/client identities only. + * + * @param params - Explicit group handle and symbol. + * @param generation - Caller session. + * @returns Success only after every owned order is proven terminal. + */ + readonly #cancelAttachedGroup = async ( + params: CancelOrderParams, 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); + ): Promise => { + const accountIndex = await this.#ensureAccountIndex(); this.#assertSession(generation); - const nonce = await nextNonce(); + const key = this.#attachedKey(accountIndex); + const lookup = async (): Promise => { + const groups = await this.#readAttachedGroups(key); + this.#assertSession(generation); + const group = groups.find( + (entry) => + entry.groupId === params.orderId && + entry.symbol === params.symbol && + entry.accountIndex === accountIndex, + ); + if (!group) { + throw new Error( + 'Unknown Lighter attached-order group for this account and symbol', + ); + } + return group; + }; + await lookup(); + await this.#ensureSignerReady(); 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}`); - } + const authToken = await this.#getAuthToken(); this.#assertSession(generation); - const result = await submit( - LIGHTER_TX_TYPE_CHANGE_PUB_KEY, - signed.txInfo, - undefined, - extractDispatchIdentity(signed), + return await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + const group = await lookup(); + if ( + group.submission === 'canceled' || + group.submission === 'completed' + ) { + return { + success: true, + orderId: params.orderId, + providerId: 'lighter', + }; + } + // Abandon only undispatched intent or durably proven non-acceptance. + if (group.submission === 'prepared' && group.txHash === null) { + group.submission = 'canceled'; + await this.#writeAttachedGroup(key, group, generation); + return { + success: true, + orderId: params.orderId, + providerId: 'lighter', + }; + } + const initialSnapshot = await this.#readAttachedOrders( + accountIndex, + authToken, + group.orders[0][0], + generation, + [group], + ); + const initialRows = correlateLighterAttachedOrders( + group, + initialSnapshot.rows, + ); + const hasLegs = initialRows.some((row) => row !== null); + if (hasLegs && group.nonAcceptance !== undefined) { + delete group.nonAcceptance; + group.submission = 'accepted'; + await this.#writeAttachedGroup(key, group, generation); + } + if ( + (group.submission === 'unknown' || + (group.submission === 'accepted' && !hasLegs)) && + group.txHash !== null + ) { + const transaction = await this.#clientService.getTx(group.txHash); + this.#assertSession(generation); + const matches = + transaction !== null && + typeof transaction.hash === 'string' && + transaction.hash.toLowerCase().replace(/^0x/u, '') === + group.txHash.toLowerCase().replace(/^0x/u, '') && + transaction.accountIndex === accountIndex && + transaction.apiKeyIndex === group.apiKeyIndex && + transaction.nonce === group.nonce; + const outcome = matches + ? getLighterTransactionOutcome(transaction.status) + : null; + if (outcome === 'executed' && group.nonAcceptance !== undefined) { + delete group.nonAcceptance; + group.submission = 'accepted'; + await this.#writeAttachedGroup(key, group, generation); + } + const neverLanded = + group.nonAcceptance !== undefined && + transaction === null && + group.expiresAt !== null && + Date.now() > group.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS; + // Neither stale proof nor missing history can erase observed ownership. + if ( + !hasLegs && + group.venueIds.every((id) => id === null) && + initialSnapshot.complete && + (outcome === 'failed' || neverLanded) + ) { + group.submission = 'canceled'; + await this.#writeAttachedGroup(key, group, generation); + return { + success: true, + orderId: params.orderId, + providerId: 'lighter', + }; + } + } + const read = async (): Promise => { + const snapshot = await this.#readAttachedOrders( + accountIndex, + authToken, + group.orders[0][0], + generation, + [group], + ); + const rows = correlateLighterAttachedOrders(group, snapshot.rows); + if (rows.some((row) => row === null)) { + throw new Error( + 'Lighter attached-order identity is unresolved; no cancellation guarantee', + ); + } + return rows.filter((row): row is LighterApiOrder => row !== null); + }; + const terminal = (row: LighterApiOrder): boolean => + this.#isAttachedOrderTerminal(row, group.symbol); + if (initialRows.some((row) => row === null)) { + throw new Error( + 'Lighter attached-order identity is unresolved; no cancellation guarantee', + ); + } + const rows = initialRows.filter( + (row): row is LighterApiOrder => row !== null, + ); + group.venueIds = rows.map((row) => String(row.orderIndex)); + await this.#writeAttachedGroup(key, group, generation); + // Re-read after parent cancellation: the venue may already have canceled + // its children, or a fill may have made a leg terminal in the meantime. + for (let index = 0; index < rows.length; index += 1) { + const row = (index === 0 ? rows : await read())[index]; + if (terminal(row)) { + continue; + } + const signed = await this.#getSignerBridge().execute({ + function: '_signCancelOrder', + params: [ + accountIndex, + row.marketIndex, + String(row.orderIndex), + await nextNonce(), + ], + }); + if (signed.error) { + throw new Error(signed.error); + } + await submit(LIGHTER_TX_TYPE_CANCEL_ORDER, signed.txInfo, undefined, { + ...extractDispatchIdentity(signed), + intent: `cancelAttached:${group.groupId}:${row.orderIndex}`, + }); + } + if (!(await read()).every(terminal)) { + throw new Error( + 'Lighter attached cancellation is pending; retained exact owned identities', + ); + } + group.submission = 'canceled'; + await this.#writeAttachedGroup(key, group, generation); + return { + success: true, + orderId: params.orderId, + providerId: 'lighter', + }; + }, + generation, ); - 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. + * Reject a crossing post-only intent using a bounded fresh public read. + * TIF2 remains the venue-side crossing guard if the book moves after this read. * - * @returns Auth token string. + * @param marketId - Exact native market. + * @param isBuy - Intent side. + * @param price - Integerized wire price projected onto the native tick grid. + * @param generation - Optional financial intent fence. */ - /** Tail of the serialized venue-write chain (see #withVenueNonce). */ - #writeChain: Promise = Promise.resolve(); + async #assertPostOnlyBook( + marketId: number, + isBuy: boolean, + price: number, + generation?: number, + ): Promise { + const startedAt = Date.now(); + const book = await this.#clientService.getOrderBookOrders(marketId); + if (generation !== undefined) { + this.#assertSession(generation); + } + if ( + Date.now() < startedAt || + Date.now() - startedAt > LIGHTER_POST_ONLY_QUOTE_MAX_AGE_MS + ) { + throw new Error('Lighter post-only book is stale'); + } + const opposing = isBuy ? book.asks : book.bids; + const total = isBuy ? book.totalAsks : book.totalBids; + if (total < opposing.length || (total > 0 && opposing.length === 0)) { + throw new Error('Lighter post-only book is incomplete'); + } + for (const row of opposing) { + const restingPrice = parseFinitePositive(row.price); + if ( + restingPrice === null || + parseFinitePositive(row.remainingBaseAmount) === null + ) { + throw new Error('Lighter post-only book is malformed'); + } + if (isBuy ? price >= restingPrice : price <= restingPrice) { + throw new Error('Lighter post-only order would cross the book'); + } + } + } - /** Every client order id this instance has issued (collision set). */ - readonly #issuedClientOrderIds = new Set(); + async placeOrder( + input: OrderParams, + inheritedGeneration?: number, + ): Promise { + const params = { ...input }; + if ( + params.expectedScaleLadder !== undefined && + params.orderType !== 'scale' + ) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE, + }; + } + // 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; + let postOnlyClientOrderId: string | undefined; + let attachedGroup: LighterAttachedGroup | undefined; + let attachedGroupPersisted = false; + if (params.orderType === 'scale') { + return this.#placeScaleOrder(params, inheritedGeneration); + } + try { + if (params.orderType === 'chase' && this.#chaseTestnetProbe) { + return await this.#placeChaseProbe(params); + } + if (params.orderType === 'twap' && this.#nativeTwapTestnetProbe) { + return await this.#placeNativeTwapProbe(params); + } + if ( + params.orderType !== 'limit' && + params.orderType !== 'market' && + !isTriggerOrderType(params.orderType) + ) { + return { success: false, error: LIGHTER_NOT_SUPPORTED_ERROR }; + } + const triggerIntentError = getLighterTriggerIntentError(params); + if (triggerIntentError) { + return { success: false, error: triggerIntentError }; + } + // User intent is never silently dropped: fields this venue path does + // not execute are rejected so the caller can adapt, not surprised. + const attachedIntentError = getLighterAttachedIntentError(params); + if (attachedIntentError) { + return { success: false, error: attachedIntentError }; + } + const hasAttached = + params.takeProfitPrice !== undefined || + params.stopLossPrice !== undefined; + const postOnlyError = getLighterPostOnlyIntentError(params); + if (postOnlyError) { + return { success: false, error: postOnlyError }; + } + 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( + isTriggerOrderType(params.orderType) || + hasAttached || + params.timeInForce === 'ALO' || + params.marginMode !== undefined, + ); + const market = markets.get(params.symbol); + if (!market) { + return { + success: false, + error: `Unknown Lighter market: ${params.symbol}`, + }; + } + if ( + params.timeInForce === 'ALO' && + (market.status !== 'active' || market.marketType !== 'perp') + ) { + return { + success: false, + error: 'Lighter post-only requires an active perpetual market', + }; + } + if ( + (isTriggerOrderType(params.orderType) || hasAttached) && + (market.status !== 'active' || market.marketType !== 'perp') + ) { + return { + success: false, + error: 'Lighter trigger orders require an active perpetual market', + }; + } + if (isLimitExecutionOrderType(params.orderType) && !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`, + }; + } + } - /** - * 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`, + // Slippage tolerance: caller basis points win, then the deprecated + // decimal field, then the venue-conventional 5%. + const slippageFraction = + params.maxSlippageBps === undefined + ? (params.slippage ?? LIGHTER_DEFAULT_SLIPPAGE_BPS / 10_000) + : 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; + const triggerPrices = isTriggerOrderType(params.orderType) + ? resolveLighterTriggerPrices(params, market) + : undefined; + if (triggerPrices) { + referencePrice = triggerPrices.referencePrice; + } else 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 = triggerPrices?.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' }; + } + if (params.usdAmount === undefined) { + const requestedSizeInt = toSignerWireInteger( + requestedSize, + market.supportedSizeDecimals, ); + if ( + fromLighterInteger(requestedSizeInt, market.supportedSizeDecimals) !== + requestedSize + ) { + return { + success: false, + error: `Order size ${params.size} does not align with the Lighter size grid`, + }; + } + } + const minSize = isLighterMakerOrder(params) + ? computeLighterMinOrderSize(market, referencePrice) + : 0; + if (minSize > 0 && 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. + // A dormant trigger must retain its quantity even if the position + // grows before activation; today's full-close proof cannot authorize + // a larger future close. + const verifiedFullClose = + params.reduceOnly && !triggerPrices + ? 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}`, + }; + } } - attempts += 1; - const [high, low] = randomUint24Pair(); - const candidate = high * 2 ** 24 + low; - if (candidate === 0 || this.#issuedClientOrderIds.has(candidate)) { - continue; + const size = + minSize > 0 ? Math.max(requestedSize, minSize) : requestedSize; + + // 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 timeInForce = + params.timeInForce === 'ALO' + ? LIGHTER_TIME_IN_FORCE_POST_ONLY + : LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME; + let orderTypeInt = LIGHTER_ORDER_TYPE_MARKET; + if (isTriggerOrderType(params.orderType)) { + orderTypeInt = LIGHTER_TRIGGER_WIRE_TYPES[params.orderType]; + } else if (params.orderType === 'limit') { + orderTypeInt = LIGHTER_ORDER_TYPE_LIMIT; } - 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); + const attachedChildren = resolveLighterAttachedChildren( + params, + market, + referencePrice, + ); + if (params.timeInForce === 'ALO') { + await this.#assertPostOnlyBook( + market.marketId, + params.isBuy, + fromLighterInteger(priceInt, market.supportedPriceDecimals), + generationAtIntent, + ); + } + const explicitMarginMode = await this.#validateExplicitMarginMode( + params, + market, + generationAtIntent, + ); + const leverageImfHundredths = + explicitMarginMode === null + ? await this.#resolveLeverageIntent(params) + : Math.round(10_000 / (params.leverage as number)); + // Margin mode is sent only with an explicit leverage update. An + // omitted leverage leaves both leverage and margin mode unchanged; + // an explicit update preserves an existing position's venue mode and + // uses isolated only for a flat market. + const leverageMarginMode = + leverageImfHundredths === null + ? null + : (explicitMarginMode ?? + (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); - // 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. + if (hasAttached) { + const preflightAccount = await this.#ensureAccountIndex(); this.#assertSession(generationAtIntent); - const nonceResponse = await this.#clientService.getNextNonce( - accountIndex, - this.#apiKeyIndex, + const priorGroups = await this.#readAttachedGroups( + this.#attachedKey(preflightAccount), ); 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(), - ); + if ( + priorGroups.some( + (group) => + group.submission === 'prepared' || group.submission === 'unknown', + ) + ) { + throw new Error( + 'Lighter attached placement has unresolved intent; review or cancel its exact group before placing another', + ); + } } - 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. + await this.#ensureSignerReady(); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generationAtIntent); + const attachedKey = this.#attachedKey(accountIndex); + if (hasAttached) { + await this.#readAttachedGroups(attachedKey); 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)', + } + const [clientOrderIndex, ...attachedClientIds] = + this.#allocateClientOrderIndexes(1 + attachedChildren.length); + if (params.timeInForce === 'ALO') { + postOnlyClientOrderId = String(clientOrderIndex); + } + + // 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 (hasAttached) { + const existing = await this.#readAttachedGroups(attachedKey); + this.#assertSession(generationAtIntent); + if ( + existing.some( + (group) => + group.submission === 'prepared' || + group.submission === 'unknown', + ) + ) { + throw new Error( + 'Lighter attached placement has unresolved intent; review or cancel its exact group before placing another', + ); + } + const allocatedIds = new Set([ + clientOrderIndex, + ...attachedClientIds, + ]); + if ( + existing.some((group) => + group.orders.some((order) => allocatedIds.has(order[1])), + ) + ) { + throw new Error( + 'Lighter attached client identity was allocated concurrently; refresh and retry', + ); + } + } + if (params.timeInForce === 'ALO') { + await this.#assertPostOnlyBook( + market.marketId, + params.isBuy, + fromLighterInteger(priceInt, market.supportedPriceDecimals), + generationAtIntent, ); } - 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) { + await this.#validateExplicitMarginMode( + params, + market, + generationAtIntent, + ); + if (leverageImfHundredths !== null && leverageMarginMode !== 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: `${explicitMarginMode === null ? 'updateLeverage' : 'updateMarginMode'}:${params.symbol}:${String(params.leverage)}`, + requireExecution: explicitMarginMode !== null, + beforeDispatch: async () => { + await this.#validateExplicitMarginMode( + params, + market, + generationAtIntent, + ); + }, + }, + ); + leverageCommitted = true; + } + await this.#validateExplicitMarginMode( + params, + market, + generationAtIntent, + true, + ); + const parent: LighterCreateOrderWireParams = [ + market.marketId, + clientOrderIndex, + String(sizeInt), + String(priceInt), + params.isBuy ? 0 : 1, + orderTypeInt, + isLighterMakerOrder(params) + ? timeInForce + : LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + params.reduceOnly ? 1 : 0, + String(triggerPrices?.triggerPriceInt ?? LIGHTER_NO_TRIGGER_PRICE), + // Triggers remain pending even when they execute as IOC. + // The signer expands -1 to its 28-day default expiry; + // only ordinary immediate orders use a zero expiry. + isTriggerOrderType(params.orderType) || isLighterMakerOrder(params) + ? LIGHTER_ORDER_EXPIRY_NONE + : 0, + ]; + let signed: LighterTxResult; + if (attachedChildren.length > 0) { + const children = attachedChildren.map( + (child, index): LighterCreateOrderWireParams => { + const copy: LighterCreateOrderWireParams = [...child]; + copy[1] = attachedClientIds[index]; + return copy; + }, + ); + const orders: LighterAttachedGroup['orders'] = + children.length === 1 + ? [parent, children[0]] + : [parent, children[0], children[1]]; + attachedGroup = { + version: 1, + preparedAt: Date.now(), + groupId: `${LIGHTER_ATTACHED_HANDLE_PREFIX}${attachedKey.slice('lighterAttachedOrders:'.length)}:${market.marketId}:${clientOrderIndex}`, + symbol: params.symbol, + accountIndex, + apiKeyIndex: this.#apiKeyIndex, + submission: 'prepared', + orders, + txHash: null, + nonce: null, + expiresAt: null, + venueIds: orders.map(() => null), + }; + await this.#writeAttachedGroup( + attachedKey, + attachedGroup, + generationAtIntent, + ); + attachedGroupPersisted = true; + const nonce = await nextNonce(); + attachedGroup.nonce = nonce; + signed = await this.#getSignerBridge().execute({ + function: '_signCreateGroupedOrders', + params: + orders.length === 2 + ? [ + accountIndex, + LIGHTER_GROUPING_ONE_TRIGGERS_THE_OTHER, + 2, + ...orders[0], + ...orders[1], + nonce, + ] + : [ + accountIndex, + LIGHTER_GROUPING_ONE_TRIGGERS_OCO, + 3, + ...orders[0], + ...orders[1], + ...orders[2], + nonce, + ], + }); + } else { + signed = await this.#getSignerBridge().execute({ + function: '_signCreateOrder', + params: [accountIndex, ...parent, await nextNonce()], + }); + } + if (signed.error) { + throw new Error(`Lighter order signing failed: ${signed.error}`); + } + const dispatchIdentity = extractDispatchIdentity(signed); + if (attachedGroup) { + this.#assertSession(generationAtIntent); + if ( + dispatchIdentity.txHash === null || + dispatchIdentity.expiresAt === null + ) { + throw new Error( + 'Lighter attached signer omitted transaction identity', + ); + } + attachedGroup.orderExpiries = requireSignedOrderExpiries( + signed, + attachedGroup.orders.map((order) => order[1]), + parent[9] === 0 ? [parent[1]] : [], + ); + const childExpiries = attachedGroup.orderExpiries.slice(1); + if ( + new Set(childExpiries).size !== 1 || + (parent[9] !== 0 && + attachedGroup.orderExpiries[0] !== childExpiries[0]) + ) { throw new Error( - 'Too many unresolved Lighter dispatches; refusing further writes until they resolve', + 'Lighter attached signer changed grouped expiry compatibility', ); } - 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, - ), + attachedGroup.txHash = dispatchIdentity.txHash; + attachedGroup.expiresAt = dispatchIdentity.expiresAt; + attachedGroup.submission = 'unknown'; + await this.#writeAttachedGroup( + attachedKey, + attachedGroup, + generationAtIntent, + ); + } + const response = await submit( + attachedGroup + ? LIGHTER_TX_TYPE_CREATE_GROUPED_ORDERS + : LIGHTER_TX_TYPE_CREATE_ORDER, + signed.txInfo, + undefined, + { + ...extractDispatchIdentity(signed), + intent: attachedGroup + ? `placeAttached:${attachedGroup.groupId}` + : `placeOrder:${params.symbol}:${clientOrderIndex}`, + beforeDispatch: async () => { + await this.#validateExplicitMarginMode( + params, + market, + generationAtIntent, + true, + ); + }, + onNotDispatched: async () => { + if (attachedGroup) { + attachedGroup.submission = 'canceled'; + attachedGroup.nonce = null; + attachedGroup.txHash = null; + attachedGroup.expiresAt = null; + delete attachedGroup.nonAcceptance; + await this.#writeAttachedGroup( + attachedKey, + attachedGroup, + generationAtIntent, + ); + } + }, + }, + ); + if (attachedGroup) { + attachedGroup.submission = 'accepted'; + await this.#writeAttachedGroup( + attachedKey, + attachedGroup, + generationAtIntent, + ); + } + return response; + }, + generationAtIntent, ); - 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, - ); + this.#deps.debugLogger.log('[LighterProvider] Order placed', { + symbol: params.symbol, + clientOrderIndex, + txHash: result.txHash, + }); - /** - * Re-create OUR venue client on the shared bridge after another - * identity overwrote the singleton. MUST run while holding the bridge - * mutex. The bridge owns key generation and persistence. - * - * @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', - ); - } - this.#assertSession(generation); - const recreated = await this.#getSignerBridge().createClient({ - chainId: recreateParams.chainId, - accountIndex: recreateParams.accountIndex, - nonce, - apiKeyIndex: this.#apiKeyIndex, - }); - if (recreated.error || !recreated.success) { - throw new Error( - `Lighter signer client re-establishment failed: ${recreated.error ?? 'unknown'}`, + return { + success: true, + orderId: String(clientOrderIndex), + submittedSize: String(size), + ...(attachedGroup && attachedGroupPersisted + ? { attachedOrderGroup: toAttachedOrderGroup(attachedGroup) } + : {}), + 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}`, + ...(attachedGroup && attachedGroupPersisted + ? { attachedOrderGroup: toAttachedOrderGroup(attachedGroup) } + : {}), + ...(postOnlyClientOrderId + ? { orderId: postOnlyClientOrderId, providerId: 'lighter' as const } + : {}), + ...(leverageCommitted + ? { partialState: { leverageUpdated: Number(params.leverage) } } + : {}), + }; } - this.#assertSession(generation); - bridgeClientOwners.set(this.#rawSignerBridge(), identity); - }; + } - readonly #getAuthToken = async (): Promise => { + /** + * Collect owned native schedules without registering keys or financial writes. + * Raw observations preserve mapping uncertainty for the bounded runtime probe. + * + * @returns Exact owned records and parent/child execution observations. + */ + async getNativeTwapObservations(): 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(); + this.#assertSession(generation); + const owner = this.#nativeTwapOwner(accountIndex); + const records = await this.#nativeTwapService.list(owner); + this.#assertSession(generation); + if (records.length === 0) { + return []; } - return this.#marketsBySymbol; - }; - - // ============================================================================ - // Market Data Operations (Public Reads) - // ============================================================================ - - async getMarkets(_params?: GetMarketsParams): Promise { - try { - const markets = await this.#clientService.getOrderBooks(); - await this.#ensureMarketMargins(); - return markets - .filter((market) => market.marketType === 'perp') - .flatMap((market) => { - const margins = this.#marginBySymbol.get(market.symbol); - if (!margins) { - // #ensureMarketMargins deliberately omits inactive rows whose - // retired risk metadata cannot produce a canonical MarketInfo. - if (market.status === 'inactive') { - return []; - } + const authority = await this.#getNativeProbeReadToken( + accountIndex, + generation, + ); + this.#assertSession(generation); + return await this.#nativeTwapService.observe( + owner, + this.#clientService, + authority.token, + ); + } + + async #withNativeProbeStart( + owner: LighterTwapOwner, + strategy: 'twap' | 'chase', + generation: number, + start: () => Promise, + ): Promise { + return await withProcessMutex( + `lighterNativeProbe:${owner.network}:${owner.accountIndex}`, + async () => { + this.#assertSession(generation); + if (strategy === 'chase') { + const records = await this.#nativeTwapService.list(owner); + this.#assertSession(generation); + if (records.some((record) => record.placement.phase !== 'failed')) { throw new Error( - `${LIGHTER_DATA_INTEGRITY_PREFIX} missing authoritative leverage for ${market.symbol}`, + 'An earlier Lighter native probe remains unresolved for this account', ); } - const adapted = adaptMarketFromLighter( - market, - deriveLighterMaxLeverage(margins.minInitial, market.symbol), - ); - // minBaseAmount and minQuoteAmount are maker-only. Market and - // IOC orders can use one base-size tick, so the public universal - // minimum must report that tick rather than the maker floor. - delete adapted.minimumOrderSize; - if (margins?.lastTradePrice && margins.lastTradePrice > 0) { - const oneTickUsd = - margins.lastTradePrice / 10 ** market.supportedSizeDecimals; - if (Number.isFinite(oneTickUsd) && oneTickUsd > 0) { - adapted.minimumOrderSize = oneTickUsd; - } + } else { + const records = await this.#chaseService.list(owner, { + assertCurrent: () => this.#assertSession(generation), + }); + this.#assertSession(generation); + if ( + records.some( + (record) => + record.status === 'active' || + record.status === 'termination_pending', + ) + ) { + throw new Error( + 'An earlier Lighter native probe remains unresolved for this account', + ); } - return [adapted]; - }); - } catch (caughtError) { - const wrappedError = ensureError( - caughtError, - 'LighterProvider.getMarkets', - ); - this.#deps.debugLogger.log('[LighterProvider] getMarkets failed', { - error: String(wrappedError), - ...this.#getErrorContext('getMarkets'), - }); - if ( - this.#isUnsupportedCapabilityError(caughtError) || - this.#isDataIntegrityError(caughtError) - ) { - throw caughtError; - } - return []; + } + return await start(); + }, + ); + } + + async #getNativeProbeReadToken( + accountIndex: number, + generation: number, + ): Promise<{ token: string; apiKeyIndex: number; publicKey: string }> { + return await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}`, + async () => await this.#getRecoveryReadToken(accountIndex, generation), + ); + } + + #nativeTwapOwner(accountIndex: number): LighterTwapOwner { + if (!this.#boundAddress) { + throw new Error('Lighter TWAP wallet is unbound'); } + return { + wallet: this.#boundAddress, + network: this.#isTestnet ? 'testnet' : 'mainnet', + accountIndex, + apiKeyIndex: this.#apiKeyIndex, + }; } - async getMarketDataWithPrices(): Promise { - try { - const response = await this.#clientService.getOrderBookDetails(); - return response.orderBookDetails - .filter((detail) => detail.marketType === 'perp') - .filter((detail) => !isInactiveMarketWithoutUsableRiskMetadata(detail)) - .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'), - }, - ); - if ( - this.#isUnsupportedCapabilityError(caughtError) || - this.#isDataIntegrityError(caughtError) - ) { - throw caughtError; - } - return []; + #interruptChase(reason?: ChaseOrderStatus): void { + this.#chaseGeneration += 1; + this.#chaseService.interrupt(reason); + for (const timer of this.#chaseTimers.values()) { + clearTimeout(timer); } + this.#chaseTimers.clear(); } - // ============================================================================ - // Account Operations - // ============================================================================ + #chaseOwner(accountIndex: number): LighterChaseOwner { + if (!this.#boundAddress) { + throw new Error('Lighter Chase wallet is unbound'); + } + return { + wallet: this.#boundAddress, + network: this.#isTestnet ? 'testnet' : 'mainnet', + accountIndex, + apiKeyIndex: this.#apiKeyIndex, + }; + } - async getPositions(_params?: GetPositionsParams): Promise { - this.#ensureSessionBinding(); - const generation = this.#sessionGeneration; - // Risk metadata is required for every returned position. A transport - // failure must reject instead of looking like an authoritative empty - // account. - await this.#ensureMarketMargins(); - try { - const accountIndex = await this.#ensureAccountIndex(); - const response = - await this.#clientService.getAccountByIndex(accountIndex); - this.#assertSession(generation); - const account = response.accounts[0]; - if (!account?.positions) { - return []; + async #prepareChaseIntent( + params: OrderParams, + generation: number, + ): Promise { + if (params.providerId !== undefined && params.providerId !== 'lighter') { + throw new Error('Lighter Chase provider route mismatch'); + } + if (!this.#chaseTestnetProbe || !this.#isTestnet) { + throw new Error('Lighter Chase probe is unavailable'); + } + const unsupported: (keyof OrderParams)[] = [ + 'price', + 'triggerPrice', + 'timeInForce', + 'takeProfitPrice', + 'stopLossPrice', + 'takeProfitSize', + 'stopLossSize', + 'clientOrderId', + 'marginMode', + 'tpslLinkage', + 'grouping', + 'twapDuration', + 'twapRandomize', + 'scaleMinPrice', + 'scaleMaxPrice', + 'scaleNumOrders', + 'scaleSkew', + 'isFullClose', + ]; + if ( + params.leverage !== 1 || + params.reduceOnly === true || + unsupported.some((key) => params[key] !== undefined) + ) { + throw new Error( + 'Lighter Chase probe requires exact opening size, existing 1x leverage and no unsupported fields', + ); + } + const intervalMs = + params.chaseIntervalMs ?? LIGHTER_CHASE_DEFAULT_INTERVAL_MS; + const maxDurationMs = + params.chaseMaxDurationMs ?? LIGHTER_CHASE_DEFAULT_DURATION_MS; + const maxRepricings = + params.chaseMaxRepricings ?? LIGHTER_CHASE_DEFAULT_REPRICINGS; + const maxDistanceBps = + params.chaseMaxDistanceBps ?? LIGHTER_CHASE_DEFAULT_DISTANCE_BPS; + if ( + !Number.isSafeInteger(intervalMs) || + intervalMs < LIGHTER_CHASE_MIN_INTERVAL_MS || + !Number.isSafeInteger(maxDurationMs) || + maxDurationMs < intervalMs || + maxDurationMs > LIGHTER_CHASE_MAX_DURATION_MS || + !Number.isSafeInteger(maxRepricings) || + maxRepricings < 0 || + maxRepricings > LIGHTER_CHASE_MAX_REPRICINGS || + !Number.isFinite(maxDistanceBps) || + maxDistanceBps <= 0 || + maxDistanceBps >= LIGHTER_CHASE_MAX_DISTANCE_BPS + ) { + throw new Error( + 'Lighter Chase probe requires bounded interval, duration, repricing and distance', + ); + } + const market = (await this.#ensureMarkets(true)).get(params.symbol); + this.#assertSession(generation); + if (market?.status !== 'active' || market.marketType !== 'perp') { + throw new Error('Lighter Chase requires an active perpetual market'); + } + if (!/^\d+(?:\.\d+)?$/u.test(params.size)) { + throw new Error('Lighter Chase requires exact positive size'); + } + const requestedSize = new BigNumber(params.size); + const size = + params.usdAmount === undefined + ? requestedSize + : requestedSize.decimalPlaces( + market.supportedSizeDecimals, + BigNumber.ROUND_DOWN, + ); + const units = size.shiftedBy(market.supportedSizeDecimals); + if ( + !units.isInteger() || + units.lt(1) || + units.gt(LIGHTER_MAX_BASE_AMOUNT) + ) { + throw new Error('Lighter Chase size is outside the native grid'); + } + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const startedAt = Date.now(); + const book = await this.#clientService.getOrderBookOrders(market.marketId); + this.#assertSession(generation); + if ( + Date.now() < startedAt || + Date.now() - startedAt > LIGHTER_CHASE_QUOTE_MAX_AGE_MS + ) { + throw new Error('Lighter Chase book is stale'); + } + const arrivalPrice = readLighterChaseQuote(book, { + isBuy: params.isBuy, + accountIndex, + priceDecimals: market.supportedPriceDecimals, + }); + let maxNotional = String(LIGHTER_NATIVE_PROBE_MAX_NOTIONAL); + if (params.usdAmount !== undefined) { + const budget = new BigNumber(params.usdAmount); + if ( + !/^(?:\d+(?:\.\d*)?|\.\d+)$/u.test(params.usdAmount) || + !budget.isFinite() || + !budget.gt(0) || + budget.gt(LIGHTER_NATIVE_PROBE_MAX_NOTIONAL) + ) { + throw new Error( + 'Lighter Chase USD amount must be positive and within the 20 USD cap', + ); } - // 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.#maxLeverageForPosition(position), - ), - ) - .filter((position) => parseFloat(position.size) !== 0); - } catch (caughtError) { + const referencePrices = [ + arrivalPrice, + params.currentPrice, + params.priceAtCalculation, + ].filter((value) => value !== undefined); if ( - this.#isUnsupportedCapabilityError(caughtError) || - this.#isDataIntegrityError(caughtError) + referencePrices.some((value) => { + const price = new BigNumber(value); + return ( + !price.isFinite() || + !price.gt(0) || + !budget + .div(price) + .decimalPlaces(market.supportedSizeDecimals, BigNumber.ROUND_DOWN) + .eq(size) + ); + }) ) { - // Capability gates and venue-data integrity failures must surface, - // never degrade into empty state that can preserve stale views. - throw caughtError; + throw new Error( + 'Lighter Chase size does not match its USD amount and current price', + ); } - const wrappedError = ensureError( - caughtError, - 'LighterProvider.getPositions', + maxNotional = budget.toFixed(); + } + if ( + size.times(arrivalPrice).gt(LIGHTER_NATIVE_PROBE_MAX_NOTIONAL) || + size.times(arrivalPrice).lt(market.minQuoteAmount) || + size.lt(market.minBaseAmount) + ) { + throw new Error( + 'Lighter Chase must fit native minimums and the aggregate 20 USD cap', ); - this.#deps.debugLogger.log('[LighterProvider] getPositions failed', { - error: String(wrappedError), - ...this.#getErrorContext('getPositions'), - }); - return []; } + const intent: LighterChaseIntent = { + owner: this.#chaseOwner(accountIndex), + handle: 'preflight', + symbol: params.symbol, + marketId: market.marketId, + isBuy: params.isBuy, + reduceOnly: false, + originalSize: size.toFixed(), + arrivalPrice, + sizeDecimals: market.supportedSizeDecimals, + priceDecimals: market.supportedPriceDecimals, + startedAt: Date.now(), + intervalMs, + maxDurationMs, + maxRepricings, + maxDistanceBps, + maxNotional, + minBaseAmount: market.minBaseAmount, + minQuoteAmount: market.minQuoteAmount, + }; + await this.#assertChaseProbeExposure(intent, size.toFixed(), generation); + return intent; } - /** - * Report the margin mode Lighter currently binds to a market. Only an - * open position locks the mode here: the venue refuses a mode change - * while a position is open. Resting orders are not treated as a lock. - * - * @param params - Market and optional provider route. - * @returns The current lock, or unavailable when it cannot be read. - */ - async getMarginModeLock( - params: GetMarginModeLockParams, - ): Promise { - try { - this.#ensureSessionBinding(); - const generation = this.#sessionGeneration; - const wireMarginMode = await this.#readPositionMarginMode(params.symbol); - this.#assertSession(generation); - if (wireMarginMode === null) { - return { status: 'unlocked', providerId: this.protocolId }; + async #assertChaseProbeExposure( + intent: LighterChaseIntent, + remainingSize: string, + generation: number, + price = intent.arrivalPrice, + token?: string, + ): Promise { + const response = await this.#clientService.getAccountByIndex( + intent.owner.accountIndex, + ); + this.#assertSession(generation); + if (response.accounts.length !== 1) { + throw new Error('Lighter Chase account is ambiguous'); + } + const account = response.accounts[0]; + this.#assertAccountOwnership(account); + this.#assertStandardAccount(account.accountType); + const rows = (account.positions ?? []).filter( + (position) => position.marketId === intent.marketId, + ); + const expectedFilled = new BigNumber(intent.originalSize).minus( + remainingSize, + ); + if ( + rows.length !== 1 || + !new BigNumber(rows[0].initialMarginFraction).eq(100) || + !new BigNumber(rows[0].position).abs().eq(expectedFilled) || + (!expectedFilled.isZero() && (rows[0].sign === 1) !== intent.isBuy) || + account.pendingOrderCount !== 0 || + (account.positions ?? []).some( + (position) => + position.openOrderCount !== 0 || + (position.marketId !== intent.marketId && + !new BigNumber(position.position).isZero()), + ) + ) { + throw new Error( + 'Lighter Chase requires independently confirmed 1x and no unrelated exposure or orders', + ); + } + let exposure = new BigNumber(0); + for (const position of account.positions ?? []) { + const value = new BigNumber(position.positionValue); + if (!value.isFinite()) { + throw new Error('Lighter Chase account exposure is unavailable'); } - return { - status: 'locked', - providerId: this.protocolId, - marginMode: - wireMarginMode === LIGHTER_MARGIN_MODE_ISOLATED - ? 'isolated' - : 'cross', - reason: 'position', - }; - } catch (error) { - this.#deps.debugLogger.log( - '[LighterProvider] getMarginModeLock unavailable', - { - symbol: params.symbol, - error: ensureError(error, 'LighterProvider.getMarginModeLock') - .message, - }, + exposure = exposure.plus(value.abs()); + } + if ( + exposure + .plus(new BigNumber(remainingSize).times(price)) + .gt(LIGHTER_NATIVE_PROBE_MAX_NOTIONAL) + ) { + throw new Error( + 'Lighter Chase aggregate existing-plus-remaining exposure exceeds 20 USD', ); - return { - status: 'unavailable', - providerId: this.protocolId, - reason: 'provider_unavailable', - }; } - } - - 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. + if (token === undefined) { + return; + } + const active = await this.#clientService.getActiveOrders( + intent.owner.accountIndex, + token, + intent.marketId, + ); + this.#assertSession(generation); + if (active.orders.length !== 0) { + throw new Error( + 'Lighter Chase requires all prior children terminal before placement', + ); + } + } + + #chaseIo(intent: LighterChaseIntent, generation: number): LighterChaseIo { + const assertCurrent = (): void => { this.#assertSession(generation); - const account = response.accounts[0]; - if (!account) { - return EMPTY_ACCOUNT_STATE; + if ( + this.#boundAddress !== intent.owner.wallet || + this.#accountIndex !== intent.owner.accountIndex || + (this.#isTestnet ? 'testnet' : 'mainnet') !== intent.owner.network + ) { + throw new Error('Lighter Chase owner changed'); } - return adaptAccountStateFromLighter(account); - } catch (caughtError) { + }; + const assertSigning = (): void => { + assertCurrent(); if ( - this.#isUnsupportedCapabilityError(caughtError) || - this.#isDataIntegrityError(caughtError) + !this.#chaseTestnetProbe || + !this.#isTestnet || + this.#apiKeyIndex !== intent.owner.apiKeyIndex ) { - // Capability gates and venue-data integrity failures must surface, - // never degrade into empty state that can preserve stale views. - throw caughtError; + throw new Error( + 'Lighter Chase original signing authority is unavailable', + ); } - const wrappedError = ensureError( - caughtError, - 'LighterProvider.getAccountState', + }; + const reconcileDispatch = async ( + dispatch: LighterChaseDispatch, + child: LighterChaseChild, + recordEvidence: Parameters[1], + ): Promise => { + if (!dispatch.txHash || dispatch.phase === 'failed') { + return; + } + const unacknowledged = + dispatch.phase === 'attempted' && dispatch.acknowledged === false; + if (unacknowledged) { + // Clear retry eligibility before an awaited lookup can reveal a conflict + // whose persistence fails or whose owner session has already changed. + await recordEvidence(dispatch, undefined); + } + const transaction = await this.#clientService.getTx(dispatch.txHash); + if (!transaction) { + assertCurrent(); + if (unacknowledged) { + await recordEvidence(dispatch, false); + assertCurrent(); + } + if ( + dispatch.phase !== 'attempted' || + dispatch.acknowledged !== false || + !isLighterTxExpiry(dispatch.expiresAt) || + !Number.isSafeInteger(dispatch.nonce) || + (dispatch.nonce ?? -1) < 0 || + Date.now() <= dispatch.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + return; + } + const nonce = await this.#clientService.getNextNonce( + intent.owner.accountIndex, + intent.owner.apiKeyIndex, + ); + assertCurrent(); + if ( + !Number.isSafeInteger(nonce.nonce) || + nonce.nonce < 0 || + nonce.nonce > (dispatch.nonce ?? -1) + ) { + return; + } + const { token } = await this.#getNativeProbeReadToken( + intent.owner.accountIndex, + generation, + ); + assertCurrent(); + const exact = await this.#clientService.getOrdersByClientIds( + intent.owner.accountIndex, + token, + [child.clientOrderId], + ); + assertCurrent(); + if (dispatch === child.placement) { + if (!child.observation && exact.orders.length === 0) { + dispatch.phase = 'failed'; + } + return; + } + if ( + exact.orders.length !== 1 || + !child.observation || + child.observation.terminal + ) { + return; + } + const orderId = identifyLighterChaseChild( + intent, + { ...child, nonce: child.placement.nonce }, + exact.orders[0], + ); + const remaining = parseStrictDecimal( + exact.orders[0].remainingBaseAmount, + ); + if ( + orderId === child.observation.orderId && + exact.orders[0].status === 'open' && + remaining !== null && + remaining > 0 && + new BigNumber(exact.orders[0].remainingBaseAmount).lte(child.size) + ) { + dispatch.phase = 'failed'; + } + return; + } + if ( + transaction.hash.toLowerCase().replace(/^0x/u, '') !== + dispatch.txHash.toLowerCase().replace(/^0x/u, '') || + transaction.accountIndex !== intent.owner.accountIndex || + transaction.apiKeyIndex !== intent.owner.apiKeyIndex || + transaction.nonce !== dispatch.nonce + ) { + await recordEvidence(dispatch, undefined); + assertCurrent(); + throw new Error('Lighter Chase transaction identity mismatch'); + } + if (getLighterTransactionOutcome(transaction.status) === 'failed') { + assertCurrent(); + dispatch.phase = 'failed'; + } else { + // Exact venue evidence survives later indexer loss, including restart. + await recordEvidence(dispatch, true); + assertCurrent(); + } + }; + const quote = async (): Promise => { + assertCurrent(); + const started = Date.now(); + const book = await this.#clientService.getOrderBookOrders( + intent.marketId, ); - this.#deps.debugLogger.log('[LighterProvider] getAccountState failed', { - error: String(wrappedError), - ...this.#getErrorContext('getAccountState'), + assertCurrent(); + if ( + Date.now() < started || + Date.now() - started > LIGHTER_CHASE_QUOTE_MAX_AGE_MS || + Date.now() - started > LIGHTER_POST_ONLY_QUOTE_MAX_AGE_MS + ) { + throw new Error('Lighter Chase book is stale'); + } + return readLighterChaseQuote(book, { + isBuy: intent.isBuy, + accountIndex: intent.owner.accountIndex, + priceDecimals: intent.priceDecimals, }); - return EMPTY_ACCOUNT_STATE; + }; + return { + assertCurrent, + now: () => Date.now(), + allocateClientId: () => String(this.#allocateClientOrderIndexes(1)[0]), + quote, + place: async (child, hooks) => { + assertSigning(); + const market = (await this.#ensureMarkets(true)).get(intent.symbol); + assertSigning(); + if ( + market?.status !== 'active' || + market.marketType !== 'perp' || + market.marketId !== intent.marketId || + market.supportedPriceDecimals !== intent.priceDecimals || + market.supportedSizeDecimals !== intent.sizeDecimals || + !new BigNumber(market.minBaseAmount).eq(intent.minBaseAmount) || + !new BigNumber(market.minQuoteAmount).eq(intent.minQuoteAmount) + ) { + throw new Error('Lighter Chase native market constraints changed'); + } + const { token } = await this.#getNativeProbeReadToken( + intent.owner.accountIndex, + generation, + ); + assertSigning(); + await this.#assertChaseProbeExposure( + intent, + child.size, + generation, + child.price, + token, + ); + assertSigning(); + await this.#withVenueNonce( + intent.owner.accountIndex, + async (nonce, submit) => { + assertSigning(); + // Read only after preparation and nonce/bridge serialization. The + // complete book validates both sides and selects a passive price; + // the service persists its bounded final quote before signing. + const price = await quote(); + assertSigning(); + await hooks.prepareQuote(price); + assertSigning(); + const signed = await this.#getSignerBridge().execute({ + function: '_signCreateOrder', + params: [ + intent.owner.accountIndex, + intent.marketId, + Number(child.clientOrderId), + new BigNumber(child.size) + .shiftedBy(intent.sizeDecimals) + .toFixed(0), + new BigNumber(child.price) + .shiftedBy(intent.priceDecimals) + .toFixed(0), + intent.isBuy ? 0 : 1, + LIGHTER_ORDER_TYPE_LIMIT, + LIGHTER_TIME_IN_FORCE_POST_ONLY, + intent.reduceOnly ? 1 : 0, + '0', + LIGHTER_ORDER_EXPIRY_NONE, + nonce, + ], + }); + assertSigning(); + if (signed.error) { + throw new Error( + `Lighter Chase create signing failed: ${signed.error}`, + ); + } + const identity = requireSignedTxIdentity(signed); + await hooks.signed({ + nonce, + txHash: identity.txHash, + expiresAt: identity.expiresAt, + }); + assertSigning(); + await submit( + LIGHTER_TX_TYPE_CREATE_ORDER, + signed.txInfo, + hooks.accepted, + { + ...identity, + intent: `chaseCreate:${intent.symbol}:${child.clientOrderId}`, + owner: intent.handle, + beforeDispatch: async () => { + assertSigning(); + await this.#assertChaseProbeExposure( + intent, + child.size, + generation, + child.price, + token, + ); + assertSigning(); + await hooks.beforeDispatch(); + }, + onNotDispatched: hooks.notDispatched, + afterAccepted: hooks.afterAccepted, + }, + ); + }, + generation, + ); + }, + cancel: async (child, hooks) => { + assertSigning(); + const { token } = await this.#getNativeProbeReadToken( + intent.owner.accountIndex, + generation, + ); + assertSigning(); + const exact = await this.#clientService.getOrdersByClientIds( + intent.owner.accountIndex, + token, + [child.clientOrderId], + ); + assertSigning(); + if (exact.orders.length !== 1) { + throw new Error( + 'Lighter Chase exact child is not visible for cancellation', + ); + } + const orderId = identifyLighterChaseChild( + intent, + { ...child, nonce: child.placement.nonce }, + exact.orders[0], + ); + if (child.observation && child.observation.orderId !== orderId) { + throw new Error( + 'Lighter Chase child identity changed before cancellation', + ); + } + await this.#ensureSignerReady(); + assertSigning(); + await this.#withVenueNonce( + intent.owner.accountIndex, + async (nonce, submit) => { + assertSigning(); + const signed = await this.#getSignerBridge().execute({ + function: '_signCancelOrder', + params: [ + intent.owner.accountIndex, + intent.marketId, + orderId, + nonce, + ], + }); + assertSigning(); + if (signed.error) { + throw new Error( + `Lighter Chase cancel signing failed: ${signed.error}`, + ); + } + const identity = requireSignedTxIdentity(signed); + await hooks.signed({ + nonce, + txHash: identity.txHash, + expiresAt: identity.expiresAt, + }); + assertSigning(); + await submit( + LIGHTER_TX_TYPE_CANCEL_ORDER, + signed.txInfo, + hooks.accepted, + { + ...identity, + intent: `chaseCancel:${intent.symbol}:${child.clientOrderId}`, + owner: intent.handle, + beforeDispatch: async () => { + assertSigning(); + await hooks.beforeDispatch(); + assertSigning(); + }, + onNotDispatched: hooks.notDispatched, + afterAccepted: hooks.afterAccepted, + }, + ); + }, + generation, + ); + }, + observe: async (child, recordEvidence) => { + assertCurrent(); + const cancel = child.cancellations.at(-1); + if (cancel) { + await reconcileDispatch(cancel, child, recordEvidence); + } + + const { token } = await this.#getNativeProbeReadToken( + intent.owner.accountIndex, + generation, + ); + assertCurrent(); + const exact = await this.#clientService.getOrdersByClientIds( + intent.owner.accountIndex, + token, + [child.clientOrderId], + ); + assertCurrent(); + if (exact.orders.length === 0) { + if (!child.observation) { + await reconcileDispatch(child.placement, child, recordEvidence); + if (child.placement.phase === 'failed') { + return null; + } + } + throw new LighterChaseObservationPendingError( + 'Lighter Chase exact child visibility remains pending', + ); + } + if (exact.orders.length !== 1) { + throw new Error('Lighter Chase exact child visibility is uncertain'); + } + identifyLighterChaseChild( + intent, + { ...child, nonce: child.placement.nonce }, + exact.orders[0], + ); + const order = exact.orders[0]; + if (!Number.isSafeInteger(order.orderIndex) || order.orderIndex <= 0) { + throw new Error('Lighter Chase exact order identity is unavailable'); + } + const trades: LighterRestTrade[] = []; + const cursors = new Set(); + let cursor: string | undefined; + let complete = false; + for (let page = 0; page < LIGHTER_NATIVE_PROBE_PAGE_LIMIT; page += 1) { + const result = await this.#clientService.getTrades( + intent.owner.accountIndex, + token, + { + marketId: intent.marketId, + limit: LIGHTER_NATIVE_PROBE_PAGE_SIZE, + orderIndex: String(order.orderIndex), + aggregate: false, + cursor, + }, + ); + assertCurrent(); + trades.push(...result.trades); + if (!result.nextCursor) { + complete = true; + break; + } + if (result.trades.length === 0 || cursors.has(result.nextCursor)) { + throw new Error('Lighter Chase trade pagination is incomplete'); + } + cursors.add(result.nextCursor); + cursor = result.nextCursor; + } + if (!complete) { + throw new Error( + 'Lighter Chase trade history exceeds the bounded collector', + ); + } + const final = await this.#clientService.getOrdersByClientIds( + intent.owner.accountIndex, + token, + [child.clientOrderId], + ); + assertCurrent(); + if ( + final.orders.length !== 1 || + JSON.stringify(final.orders[0]) !== JSON.stringify(order) + ) { + throw new Error('Lighter Chase child changed during fill collection'); + } + const observation = reconcileLighterChaseChild( + intent, + { ...child, nonce: child.placement.nonce }, + order, + trades, + ); + return observation; + }, + }; + } + + #scheduleChase( + record: LighterChaseRecord, + io: LighterChaseIo, + generation: number, + ): void { + if (record.status !== 'active') { + return; } + const { handle } = record.intent; + const timer = setTimeout( + () => { + this.#chaseTimers.delete(handle); + this.#chaseService + .tick(record.intent.owner, handle, io) + .then((updated) => { + this.#assertSession(generation); + this.#scheduleChase(updated, io, generation); + }) + .catch(async (error: unknown) => { + this.#deps.debugLogger.log('[LighterProvider] Chase tick failed', { + handle, + error: ensureError(error, 'LighterProvider.chaseTick').message, + }); + this.#interruptChase(); + try { + await this.#chaseService.recordError( + record.intent.owner, + handle, + io, + ensureError(error, 'LighterProvider.chaseTick').message, + ); + } catch (recordError) { + this.#deps.debugLogger.log( + '[LighterProvider] Chase failure persistence unavailable', + { handle, error: String(recordError) }, + ); + } + }); + }, + Math.max( + 0, + Math.min( + record.intent.startedAt + record.intent.maxDurationMs, + record.lastTickAt + record.intent.intervalMs, + ) - Date.now(), + ), + ); + this.#chaseTimers.set(handle, timer); } - /** - * 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 => { + async #placeChaseProbe(input: OrderParams): Promise { + const params = { ...input }; 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), - ), + const chaseGeneration = this.#chaseGeneration; + const assertStartCurrent = (): void => { + this.#assertSession(generation); + if (chaseGeneration !== this.#chaseGeneration) { + throw new Error('Lighter Chase start interrupted during preparation'); + } + }; + const intent = await this.#prepareChaseIntent({ ...params }, generation); + assertStartCurrent(); + await this.#ensureSignerReady(); + assertStartCurrent(); + // Read the reference, clock and exposure only after signing readiness. + const freshIntent = await this.#prepareChaseIntent( + { ...params }, + generation, ); - }; - - async getOpenOrders(_params?: GetOrdersParams): Promise { - // Public reads re-kick pending journal recovery (deduped, detached). - this.#kickTpslRecovery(); + assertStartCurrent(); + Object.assign(intent, freshIntent); + intent.owner = this.#chaseOwner(intent.owner.accountIndex); + intent.handle = `lighter-chase:${this.#allocateClientOrderIndexes(1)[0]}`; + const io = this.#chaseIo(intent, generation); try { - return await this.#readOpenOrdersStrict(); - } catch (caughtError) { - if ( - this.#isUnsupportedCapabilityError(caughtError) || - this.#isDataIntegrityError(caughtError) - ) { - // Capability gates must surface, never degrade into empty state. - throw caughtError; - } - const wrappedError = ensureError( - caughtError, - 'LighterProvider.getOpenOrders', + const record = await this.#withNativeProbeStart( + intent.owner, + 'chase', + generation, + async () => { + assertStartCurrent(); + return await this.#chaseService.start(intent, io); + }, ); - this.#deps.debugLogger.log('[LighterProvider] getOpenOrders failed', { - error: String(wrappedError), - ...this.#getErrorContext('getOpenOrders'), - }); - return []; + assertStartCurrent(); + this.#scheduleChase(record, io, generation); + const current = record.children.at(-1); + const filled = new BigNumber(record.executedSize); + return { + success: record.status === 'active' || record.status === 'filled', + orderId: intent.handle, + providerId: 'lighter', + submittedSize: current ? intent.originalSize : undefined, + childOrderIds: + current?.observation && !current.observation.terminal + ? [current.observation.orderId] + : [], + ...(filled.gt(0) + ? { + filledSize: filled.toFixed(), + averagePrice: new BigNumber(record.executedNotional) + .div(filled) + .toFixed(), + } + : {}), + ...(record.status !== 'active' && record.status !== 'filled' + ? { + error: + record.error ?? + 'Lighter Chase is interrupted; reconcile its exact handle, never replay placement', + } + : {}), + }; + } catch (error) { + return { + success: false, + orderId: intent.handle, + providerId: 'lighter', + error: ensureError(error, 'LighterProvider.placeChase').message, + }; } } - - 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), - ), + + /** + * Read every durable child for one exact handle without acquiring venue authority. + * Does not bind a session, initialize signing, use transport or alter storage. + * Original account/key identities remain visible after authority disappears. + * + * @param input - Explicit handle/route and optional original owner. + * @returns Validated local history, or explicit absence. Corrupt storage rejects. + */ + async getChaseOrderOwnership( + input: GetChaseOrderOwnershipParams, + ): Promise { + const params = { + ...input, + owner: input.owner ? { ...input.owner } : undefined, + }; + if (params.providerId !== 'lighter' || !params.handle) { + throw new Error( + 'Chase ownership requires an exact handle and matching provider', ); - // 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) { + } + const wallet = this.#walletService.getUserAddress().toLowerCase(); + const network = this.#isTestnet ? 'testnet' : 'mainnet'; + const generation = this.#sessionGeneration; + const accountIndex = this.#accountIndex; + const apiKeyIndex = this.#apiKeyIndex; + const assertCurrent = (): void => { + // The usual session fence may rebind streams. This observation must only + // reject stale context, including a switch no other operation has noticed. if ( - this.#isUnsupportedCapabilityError(caughtError) || - this.#isDataIntegrityError(caughtError) + this.#isDisconnected || + generation !== this.#sessionGeneration || + wallet !== this.#walletService.getUserAddress().toLowerCase() || + network !== (this.#isTestnet ? 'testnet' : 'mainnet') || + accountIndex !== this.#accountIndex || + apiKeyIndex !== this.#apiKeyIndex ) { - // Capability gates must surface, never degrade into empty state. - throw caughtError; + throw new Error('Lighter Chase ownership context changed during read'); } - const wrappedError = ensureError( - caughtError, - 'LighterProvider.getOrders', + }; + assertCurrent(); + const unavailable = ( + reason: 'not_found' | 'owner_mismatch', + ): PerpsChaseOrderOwnership => ({ + status: 'unavailable', + providerId: 'lighter', + handle: params.handle, + reason, + }); + if ( + params.owner && + (params.owner.providerId !== 'lighter' || + params.owner.walletAddress.toLowerCase() !== wallet || + params.owner.network !== network) + ) { + return unavailable('owner_mismatch'); + } + const accounts = await this.#readRememberedRecoveryAccounts( + `lighterRecoveryAccounts:${network}:${wallet}`, + ); + assertCurrent(); + let found: LighterChaseRecord | undefined; + for (const index of accounts) { + const record = await this.#chaseService.inspect( + { wallet, network, accountIndex: index, apiKeyIndex }, + params.handle, + { assertCurrent }, ); - this.#deps.debugLogger.log('[LighterProvider] getOrders failed', { - error: String(wrappedError), - ...this.#getErrorContext('getOrders'), - }); - return []; + assertCurrent(); + if (record) { + if (found) { + throw new Error( + 'Lighter Chase handle has ambiguous durable ownership', + ); + } + found = record; + } } + if (!found) { + return unavailable('not_found'); + } + const { intent } = found; + if ( + params.owner && + (params.owner.accountIndex !== intent.owner.accountIndex || + params.owner.apiKeyIndex !== intent.owner.apiKeyIndex) + ) { + return unavailable('owner_mismatch'); + } + // Explicit projections prevent private journal extensions from leaking into + // the public contract. Transaction identity is public; signed payloads and + // raw signer/transport error messages are not. + const dispatch = ( + value: LighterChaseDispatch, + ): PerpsChaseOrderDispatch => ({ + phase: value.phase, + ...(value.nonce === undefined ? {} : { nonce: value.nonce }), + ...(value.txHash === undefined ? {} : { txHash: value.txHash }), + ...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt }), + }); + return { + status: 'available', + providerId: 'lighter', + handle: intent.handle, + owner: { + providerId: 'lighter', + walletAddress: intent.owner.wallet, + network: intent.owner.network, + accountIndex: intent.owner.accountIndex, + apiKeyIndex: intent.owner.apiKeyIndex, + }, + order: toLighterChaseOrder(found), + executedSize: found.executedSize, + executedNotional: found.executedNotional, + lastTickAt: found.lastTickAt, + ...(found.stopReason ? { stopReason: found.stopReason } : {}), + children: found.children.map((child) => ({ + clientOrderId: child.clientOrderId, + size: child.size, + price: child.price, + quotedAt: child.quotedAt, + placement: dispatch(child.placement), + cancellations: child.cancellations.map(dispatch), + ...(child.observation + ? { + observation: { + orderId: child.observation.orderId, + terminal: child.observation.terminal, + filledSize: child.observation.filledSize, + filledNotional: child.observation.filledNotional, + remainingSize: child.observation.remainingSize, + }, + } + : {}), + })), + }; } - async getCurrentAccountId(): Promise { - const address = this.#walletService.getUserAddress(); - const chainId = getLighterChainId(this.#clientService.network); - return `eip155:${chainId}:${address}`; - } - - // ============================================================================ - // 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). + * Read durable local Chase ownership without resuming a financial loop. * - * @param accountIndex - Lighter account index. - * @param market - Market metadata for the order being placed. - * @param params - The original order params carrying `leverage`. + * @returns Exact recorded cleanup identities and public observation state. */ + async getNativeChaseRecords(): Promise { + return (await this.#chaseInventory()).records; + } + /** - * Decide whether the caller's requested leverage needs a venue update. + * Keep remembered local obligations visible when current venue authority is absent. * - * @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. + * @returns Wallet/network-scoped records and availability of current venue authority. */ - 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; + async #chaseInventory(): Promise<{ + records: LighterChaseRecord[]; + canReconcile: boolean; + }> { + this.#ensureSessionBinding(); + if (!this.#boundAddress) { + return { records: [], canReconcile: false }; } - // 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; + const generation = this.#sessionGeneration; + let account: number; try { - if (params.marginMode !== undefined) { - return { - success: false, - error: PERPS_ERROR_CODES.ORDER_MARGIN_MODE_UNSUPPORTED, - }; - } - 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, - ); + account = await this.#ensureAccountIndex(); + } catch (error) { + if (error instanceof LighterAccountNotFoundError) { + this.#assertSession(generation); + const remembered = await this.#readRememberedRecoveryAccounts(); + this.#assertSession(generation); + const records: LighterChaseRecord[] = []; + for (const index of remembered) { + if ( + this.#configuredAccountIndex !== undefined && + index !== this.#configuredAccountIndex + ) { + continue; + } + records.push( + ...(await this.#chaseService.list(this.#chaseOwner(index), { + assertCurrent: () => this.#assertSession(generation), + })), + ); + this.#assertSession(generation); + } + return { records, canReconcile: false }; } - if (!(requestedSize > 0)) { - return { success: false, error: 'Order size must be positive' }; + throw error; + } + this.#assertSession(generation); + const records = await this.#chaseService.list(this.#chaseOwner(account), { + assertCurrent: () => this.#assertSession(generation), + }); + return { records, canReconcile: true }; + } + + /** + * Persist local stop intent without inventing venue authority or signing access. + * + * @param intent - Exact validated remembered record. + * @param generation - Issuing wallet/network session. + * @returns Current local storage authority without venue operations. + */ + #chaseManagementIo( + intent: LighterChaseIntent, + generation: number, + ): Pick { + const assertCurrent = (): void => { + this.#assertSession(generation); + if ( + this.#boundAddress !== intent.owner.wallet || + (this.#isTestnet ? 'testnet' : 'mainnet') !== intent.owner.network + ) { + throw new Error('Lighter Chase remembered owner changed'); } - if (params.usdAmount === undefined) { - const requestedSizeInt = toSignerWireInteger( - requestedSize, - market.supportedSizeDecimals, + }; + return { assertCurrent }; + } + + /** + * @param intent - Exact validated owned record. + * @param generation - Issuing wallet/network session. + * @param canReconcile - Whether current venue account authority is available. + * @param reason - Requested stop cause. + * @returns Confirmed settlement or explicit unresolved cleanup. + */ + async #stopChase( + intent: LighterChaseIntent, + generation: number, + canReconcile: boolean, + reason: ChaseOrderStatus, + ): Promise { + return canReconcile + ? await this.#chaseService.stop( + intent.owner, + intent.handle, + this.#chaseIo(intent, generation), + reason, + ) + : await this.#chaseService.stopLocally( + intent.owner, + intent.handle, + this.#chaseManagementIo(intent, generation), + reason, + 'Lighter Chase current venue authority is unavailable; remembered cleanup remains pending', ); - if ( - fromLighterInteger(requestedSizeInt, market.supportedSizeDecimals) !== - requestedSize - ) { - return { - success: false, - error: `Order size ${params.size} does not align with the Lighter size grid`, - }; - } - } - const minSize = isLighterMakerOrder(params) - ? computeLighterMinOrderSize(market, referencePrice) - : 0; - if (minSize > 0 && 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}`, - }; - } + } + + /** @returns Provider-bound management state, including interrupted ownership. */ + async getChaseOrders(): Promise { + return (await this.getNativeChaseRecords()).map(toLighterChaseOrder); + } + + /** @returns Backgrounded sessions, or visible pending cleanup when uncertain. */ + async suspendChaseOrders(): Promise { + this.#interruptChase('backgrounded'); + const { records, canReconcile } = await this.#chaseInventory(); + const generation = this.#sessionGeneration; + const result: ChaseOrder[] = []; + for (const record of records) { + if ( + record.status === 'active' || + record.status === 'termination_pending' + ) { + result.push( + toLighterChaseOrder( + await this.#stopChase( + record.intent, + generation, + canReconcile, + 'backgrounded', + ), + ), + ); + } else { + result.push(toLighterChaseOrder(record)); } - const size = - minSize > 0 ? Math.max(requestedSize, minSize) : requestedSize; + } + return result; + } - // 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); + async #cancelChase(params: CancelOrderParams): Promise { + if (params.providerId !== undefined && params.providerId !== 'lighter') { + throw new Error('Lighter Chase provider route mismatch'); + } + this.#chaseService.interruptHandle(params.orderId, params.symbol); + const timer = this.#chaseTimers.get(params.orderId); + if (timer) { + clearTimeout(timer); + this.#chaseTimers.delete(params.orderId); + } + const { records, canReconcile } = await this.#chaseInventory(); + const record = records.find( + (entry) => + entry.intent.handle === params.orderId && + entry.intent.symbol === params.symbol, + ); + if (!record) { + throw new Error('Unknown owned Lighter Chase handle'); + } + const result = await this.#stopChase( + record.intent, + this.#sessionGeneration, + canReconcile, + 'canceled', + ); + const settled = + result.status !== 'active' && + result.status !== 'termination_pending' && + toLighterChaseOrder(result).restingOrderId === null; + return { + success: settled, + orderId: record.intent.handle, + providerId: 'lighter', + ...(settled + ? {} + : { error: result.error ?? 'Lighter Chase cleanup remains pending' }), + }; + } - const leverageImfHundredths = await this.#resolveLeverageIntent(params); - // Margin mode is sent only with an explicit leverage update. An - // omitted leverage leaves both leverage and margin mode unchanged; - // an explicit update preserves an existing position's venue mode and - // uses isolated only for a flat market. - const leverageMarginMode = - leverageImfHundredths === null - ? null - : await this.#resolveMarginModeForSymbol(params.symbol); + /** + * Reconcile an original owned Chase cancellation using existing read authority. + * Never initialize a financial signer, allocate a nonce or submit a new cancel. + * + * @param input - Captured original owner, handle, child and cancellation identity. + * @returns Terminal management state or explicit unresolved cleanup. + */ + async reconcileChaseOrderCancellation( + input: ReconcileChaseOrderCancellationParams, + ): Promise { + const params = { + ...input, + owner: { ...input.owner }, + cancellation: { ...input.cancellation }, + }; + if ( + params.providerId !== 'lighter' || + params.owner.providerId !== 'lighter' || + !params.handle || + !params.clientOrderId + ) { + throw new Error( + 'Lighter Chase cancellation reconciliation requires an exact owner and provider route', + ); + } + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const owner: LighterChaseOwner = { + wallet: params.owner.walletAddress.toLowerCase(), + network: params.owner.network, + accountIndex: params.owner.accountIndex, + apiKeyIndex: params.owner.apiKeyIndex, + }; + if ( + owner.wallet !== this.#boundAddress || + owner.network !== (this.#isTestnet ? 'testnet' : 'mainnet') + ) { + throw new Error( + 'Lighter Chase original cancellation owner does not match the current context', + ); + } + const record = await this.#chaseService.inspect(owner, params.handle, { + assertCurrent: () => this.#assertSession(generation), + }); + this.#assertSession(generation); + if (!record || record.intent.owner.apiKeyIndex !== owner.apiKeyIndex) { + throw new Error('Unknown original owned Lighter Chase cancellation'); + } + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + if (accountIndex !== owner.accountIndex) { + throw new Error( + 'Lighter Chase original cancellation account does not match the current context', + ); + } + const io = this.#chaseIo(record.intent, generation); + const result = await this.#chaseService.reconcileCancellation( + owner, + params.handle, + params.clientOrderId, + params.cancellation, + { assertCurrent: io.assertCurrent, now: io.now, observe: io.observe }, + ); + this.#assertSession(generation); + const timer = this.#chaseTimers.get(params.handle); + if (timer) { + clearTimeout(timer); + this.#chaseTimers.delete(params.handle); + } + const order = toLighterChaseOrder(result); + return { + status: + order.status !== 'active' && + order.status !== 'termination_pending' && + order.restingOrderId === null + ? 'settled' + : 'unresolved', + providerId: 'lighter', + handle: params.handle, + order, + }; + } - // 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); + async #placeNativeTwapProbe(input: OrderParams): Promise { + const params = { ...input }; + if (!this.#isTestnet || !this.#nativeTwapTestnetProbe) { + throw new Error('Lighter native TWAP lifecycle is unavailable'); + } + const unsupportedFields = [ + 'scaleMinPrice', + 'scaleMaxPrice', + 'scaleNumOrders', + 'scaleSkew', + 'chaseIntervalMs', + 'chaseMaxDurationMs', + 'chaseMaxRepricings', + 'chaseMaxDistanceBps', + 'takeProfitSize', + 'stopLossSize', + 'clientOrderId', + 'tpslLinkage', + 'grouping', + 'isFullClose', + ] as const; + if ( + unsupportedFields.some((field) => params[field] !== undefined) || + params.leverage !== 1 || + params.marginMode !== undefined || + params.usdAmount !== undefined || + params.price !== undefined || + params.triggerPrice !== undefined || + params.timeInForce !== undefined || + params.takeProfitPrice !== undefined || + params.stopLossPrice !== undefined + ) { + throw new Error( + 'Native TWAP probe requires exact size, existing 1x leverage and no unsupported order fields', + ); + } + if ( + params.twapDuration === undefined || + !Number.isSafeInteger(params.twapDuration) || + params.twapDuration <= 0 + ) { + throw new Error('Native TWAP probe requires a positive integer duration'); + } + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const market = (await this.#ensureMarkets(true)).get(params.symbol); + this.#assertSession(generation); + if (market?.status !== 'active' || market.marketType !== 'perp') { + throw new Error('Native TWAP probe requires an active perpetual market'); + } + const slippage = + params.maxSlippageBps === undefined + ? (params.slippage ?? LIGHTER_NATIVE_PROBE_DEFAULT_SLIPPAGE) + : params.maxSlippageBps / 10_000; + const resolved = await this.#resolveMarketReferencePrice( + params.symbol, + slippage, + params.priceAtCalculation, + ); + if (resolved.error !== null) { + throw new Error(resolved.error); + } + const durationMinutes = params.twapDuration; + const prepare = ( + referencePrice: number, + nowMilliseconds: number, + ): ReturnType => { + const maximumNotional = new BigNumber(params.size) + .times(referencePrice) + .times(new BigNumber(1).plus(slippage)); + if ( + maximumNotional.gt(LIGHTER_NATIVE_PROBE_MAX_NOTIONAL) || + new BigNumber(params.size) + .times(referencePrice) + .times(new BigNumber(1).minus(slippage)) + .lt(market.minQuoteAmount) || + new BigNumber(params.size).lt(market.minBaseAmount) + ) { + throw new Error( + 'Native TWAP probe must fit venue parent minimums and the 20 USD limit', + ); + } + return prepareLighterTwapOrder({ + size: params.size, + referencePrice: String(referencePrice), + sizeDecimals: market.supportedSizeDecimals, + priceDecimals: market.supportedPriceDecimals, + isBuy: params.isBuy, + slippage: String(slippage), + durationMinutes, + nowMilliseconds, + randomize: params.twapRandomize ?? false, + reduceOnly: params.reduceOnly ?? false, + }); + }; + prepare(resolved.referencePrice, Date.now()); + const accountIndex = await this.#ensureAccountIndex(); + const response = await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generation); + if (response.accounts.length !== 1) { + throw new Error('Native TWAP probe account is ambiguous'); + } + const account = response.accounts[0]; + this.#assertAccountOwnership(account); + this.#assertStandardAccount(account.accountType); + const positions = (account.positions ?? []).filter( + (position) => position.marketId === market.marketId, + ); + if ( + positions.length !== 1 || + !new BigNumber(positions[0].initialMarginFraction).eq(100) + ) { + throw new Error( + 'Native TWAP probe requires independently confirmed existing 1x leverage', + ); + } + this.#assertSession(generation); + await this.#ensureSignerReady(); + this.#assertSession(generation); + const refreshed = await this.#resolveMarketReferencePrice( + params.symbol, + slippage, + params.priceAtCalculation, + ); + this.#assertSession(generation); + if (refreshed.error !== null) { + throw new Error(refreshed.error); + } + const startedAt = Date.now(); + const prepared = prepare(refreshed.referencePrice, startedAt); + const owner = this.#nativeTwapOwner(accountIndex); + const [clientId] = this.#allocateClientOrderIndexes(1); + const intent = { + owner, + symbol: params.symbol, + marketId: market.marketId, + clientOrderId: String(clientId), + size: params.size, + price: new BigNumber(prepared.price) + .shiftedBy(-market.supportedPriceDecimals) + .toFixed(), + isBuy: params.isBuy, + reduceOnly: params.reduceOnly ?? false, + sizeDecimals: market.supportedSizeDecimals, + priceDecimals: market.supportedPriceDecimals, + durationMinutes: params.twapDuration, + startedAt, + orderExpiry: prepared.orderExpiry, + }; + try { + await this.#withNativeProbeStart( + owner, + 'twap', + generation, + async () => + await this.#nativeTwapService.place(intent, async (hooks) => { + await this.#withVenueNonce( + accountIndex, + async (nonce, submit) => { + this.#assertSession(generation); + if ( + this.#apiKeyIndex !== owner.apiKeyIndex || + Date.now() >= intent.orderExpiry + ) { + throw new Error('Native TWAP intent became stale'); + } + const signed = await this.#getSignerBridge().execute({ + function: '_signCreateOrder', + params: [ + accountIndex, + market.marketId, + clientId, + prepared.baseAmount, + prepared.price, + prepared.isAsk, + prepared.orderType, + prepared.timeInForce, + prepared.reduceOnly, + prepared.triggerPrice, + prepared.orderExpiry, + nonce, + ], + }); + if (signed.error) { + throw new Error('Native TWAP signer refused the placement'); + } + const identity = requireSignedTxIdentity(signed); + await hooks.signed({ + nonce, + txHash: identity.txHash, + expiresAt: identity.expiresAt, + }); + await submit( + LIGHTER_TX_TYPE_CREATE_ORDER, + signed.txInfo, + undefined, + { + ...identity, + intent: `nativeTwap:${params.symbol}:${clientId}`, + beforeDispatch: hooks.beforeDispatch, + onNotDispatched: hooks.notDispatched, + }, + ); + }, + generation, + ); + }), + ); + } catch (error) { + return { + success: false, + orderId: String(clientId), + providerId: 'lighter', + error: `Native TWAP placement did not complete; reconcile this client order before retrying: ${ensureError(error, 'LighterProvider.nativeTwap').message}`, + }; + } + let observations: LighterTwapReadObservation[]; + try { + observations = await this.getNativeTwapObservations(); + } catch (error) { + return { + success: false, + orderId: String(clientId), + providerId: 'lighter', + error: `Native TWAP submitted; parent observation failed. Do not replay: ${ensureError(error, 'LighterProvider.nativeTwapRead').message}`, + }; + } + const observed = observations.find( + (entry) => entry.record.intent.clientOrderId === String(clientId), + ); + if (!observed?.observation) { + return { + success: false, + orderId: String(clientId), + providerId: 'lighter', + error: + 'Native TWAP submitted; exact parent or fill mapping remains unresolved. Do not replay.', + }; + } + return { + success: true, + orderId: observed.observation.parentOrderId, + submittedSize: params.size, + providerId: 'lighter', + }; + } - // 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 && leverageMarginMode !== 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) { + async #cancelNativeTwapProbe( + input: CancelOrderParams, + ): Promise { + const params = { ...input }; + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const records = await this.#nativeTwapService.list( + this.#nativeTwapOwner(accountIndex), + ); + this.#assertSession(generation); + const owned = records.filter( + (record) => + record.intent.symbol === params.symbol && + (record.intent.clientOrderId === params.orderId || + record.parentOrderId === params.orderId), + ); + if (owned.length !== 1) { + throw new Error( + 'Native TWAP cancellation requires an exact owned visible parent', + ); + } + const record = owned[0]; + const authority = await this.#getNativeProbeReadToken( + accountIndex, + generation, + ); + this.#assertSession(generation); + const exact = await this.#clientService.getOrdersByClientIds( + accountIndex, + authority.token, + [record.intent.clientOrderId], + ); + this.#assertSession(generation); + if (exact.orders.length !== 1) { + throw new Error( + 'Native TWAP cancellation requires an exact owned visible parent', + ); + } + const parentOrderId = identifyLighterTwapParent(record, exact.orders[0]); + await this.#ensureSignerReady(); + this.#assertSession(generation); + if (this.#apiKeyIndex !== record.intent.owner.apiKeyIndex) { + throw new Error( + 'Native TWAP cancellation requires the original signing slot', + ); + } + await this.#nativeTwapService.cancel( + record.intent.owner, + record.intent.clientOrderId, + async (hooks) => { + await this.#withVenueNonce( + record.intent.owner.accountIndex, + async (nonce, submit) => { + this.#assertSession(generation); + if (this.#apiKeyIndex !== record.intent.owner.apiKeyIndex) { throw new Error( - `Lighter leverage update failed: ${signedLeverage.error}`, + 'Native TWAP cancellation requires the original signing slot', ); } + const signed = await this.#getSignerBridge().execute({ + function: '_signCancelOrder', + params: [ + record.intent.owner.accountIndex, + record.intent.marketId, + parentOrderId, + nonce, + ], + }); + if (signed.error) { + throw new Error('Native TWAP signer refused cancellation'); + } + const identity = requireSignedTxIdentity(signed); + await hooks.signed({ + nonce, + txHash: identity.txHash, + expiresAt: identity.expiresAt, + }); await submit( - LIGHTER_TX_TYPE_UPDATE_LEVERAGE, - signedLeverage.txInfo, + LIGHTER_TX_TYPE_CANCEL_ORDER, + signed.txInfo, undefined, { - ...extractDispatchIdentity(signedLeverage), - intent: `updateLeverage:${params.symbol}:${String(params.leverage)}`, + ...identity, + intent: `cancelNativeTwap:${record.intent.symbol}:${record.intent.clientOrderId}`, + beforeDispatch: async () => { + this.#assertSession(generation); + if (this.#apiKeyIndex !== record.intent.owner.apiKeyIndex) { + throw new Error( + 'Native TWAP cancellation requires the original signing slot', + ); + } + await hooks.beforeDispatch(); + }, + onNotDispatched: hooks.notDispatched, }, ); - 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) } } - : {}), - }; + }, + generation, + ); + }, + ); + let readIssue = ''; + try { + await this.getNativeTwapObservations(); + } catch (error) { + readIssue = `; observation failed: ${ensureError(error, 'LighterProvider.nativeTwapCancelRead').message}`; } + return { + success: false, + orderId: parentOrderId, + providerId: 'lighter', + error: `Native TWAP cancellation submitted; authoritative schedule termination remains unverified${readIssue}`, + }; + } + + /** + * Refuse a management read until native parent execution totals and terminal + * schedule semantics can be established from authoritative venue evidence. + * An empty result would incorrectly imply that no native schedules exist. + * + * @returns No schedule snapshot while the lifecycle contract is unavailable. + */ + async getTwapOrders(): Promise { + throw new Error( + 'Lighter native TWAP lifecycle is unavailable: authoritative parent fills and termination are not established', + ); } async cancelOrder( params: CancelOrderParams, inheritedGeneration?: number, ): Promise { + if (params.orderType === 'chase') { + try { + return await this.#cancelChase(params); + } catch (error) { + return { + success: false, + orderId: params.orderId, + providerId: 'lighter', + error: ensureError(error, 'LighterProvider.cancelChase').message, + }; + } + } + // A generic cancel acknowledgment does not establish that a native + // schedule is terminal or that no further slices can execute. + if (params.orderType === 'twap' && this.#nativeTwapTestnetProbe) { + try { + return await this.#cancelNativeTwapProbe(params); + } catch (error) { + return { + success: false, + error: ensureError(error, 'LighterProvider.cancelNativeTwap').message, + }; + } + } + if (params.orderType === 'twap') { + return { + success: false, + error: + 'Lighter native TWAP lifecycle is unavailable: authoritative schedule termination is not established', + }; + } try { this.#ensureSessionBinding(); const generationAtIntent = inheritedGeneration ?? this.#sessionGeneration; this.#assertSession(generationAtIntent); + if (params.orderId.startsWith(LIGHTER_ATTACHED_HANDLE_PREFIX)) { + return await this.#cancelAttachedGroup(params, generationAtIntent); + } + if ( + params.orderType === 'scale' || + params.orderId.startsWith(LIGHTER_SCALE_PREFIX) + ) { + return await this.#cancelScaleOrder(params, generationAtIntent); + } await this.#ensureSignerReady(); const accountIndex = await this.#ensureAccountIndex(); const markets = await this.#ensureMarkets(); @@ -5599,40 +13497,493 @@ export class LighterProvider implements PerpsProvider { ); return { - success: true, - orderId: params.orderId, + 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) + // ============================================================================ + + /** + * Modify a verified ordinary resting limit in place. Acceptance remains + * pending until exact transaction and order observations agree. The native + * ABI has no order version, so the final venue race cannot be eliminated. + * + * @param params - Exact venue order ID and explicit price/size intent. + * @returns Truthful durable same-order outcome, never a placement receipt. + */ + async editOrder(params: EditOrderParams): Promise { + try { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + const orderId = lighterEditOrderId(params.orderId); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + if (!this.#boundAddress) { + throw new Error('Lighter native edit wallet is unbound'); + } + const scope = { + wallet: this.#boundAddress, + network: this.#isTestnet ? ('testnet' as const) : ('mainnet' as const), + accountIndex, + orderId, + }; + const key = `${LIGHTER_EDIT_JOURNAL_PREFIX}${JSON.stringify([scope.network, scope.wallet, accountIndex, orderId])}`; + // Process-wide per-order ownership excludes duplicate live providers as + // well as a restarted caller. No other operation takes this lock. + return await withProcessMutex(key, async () => { + let journal: LighterNativeEditJournal | null = null; + const read = async (): Promise => { + this.#assertSession(generation); + const raw = await this.#deps.diskCache.getItem(key); + this.#assertSession(generation); + return parseLighterNativeEditJournal(raw, scope); + }; + const save = async ( + record: LighterNativeEditJournal, + ): Promise => { + this.#assertSession(generation); + const raw = JSON.stringify(record); + const snapshot = parseLighterNativeEditJournal(raw, scope); + await this.#deps.diskCache.setItem(key, raw); + this.#assertSession(generation); + journal = snapshot; + }; + journal = await read(); + // Restore read authority in the original slot before any financial + // signer setup. Slot changes must not bypass a pending edit. + const authority = await withProcessMutex( + `lighterVenueWrite:${scope.network}:${accountIndex}`, + async () => + await this.#getRecoveryReadToken( + accountIndex, + generation, + journal?.apiKeyIndex, + ), + ); + this.#assertSession(generation); + const exactRow = async ( + clientId?: string, + ): Promise => { + const response = await this.#clientService.getEditableOrders( + accountIndex, + authority.token, + clientId ? [clientId] : undefined, + ); + this.#assertSession(generation); + const candidates = response.orders.filter( + (row) => + lighterEditOrderId(row.orderIndex) === orderId || + (clientId !== undefined && + String(row.clientOrderIndex) === clientId), + ); + if (candidates.length > 1) { + throw new Error('Lighter native edit target is ambiguous'); + } + return candidates[0] ?? null; + }; + // Only this operation's exact owned ledger entry may be retired. + // Unknown or unrelated dispatches keep their existing safeguards. + const retire = async ( + record: LighterNativeEditJournal, + consumed: boolean, + ): Promise => { + const { nonce, txHash } = record; + if (nonce === undefined || txHash === undefined) { + return; + } + await this.#withLedgerLock( + accountIndex, + async () => { + this.#assertSession(generation); + const doc = await this.#readNonceLedger( + accountIndex, + record.apiKeyIndex, + ); + this.#assertSession(generation); + const entries = doc.entries.filter( + (entry) => + entry.owner !== key || + entry.nonce !== nonce || + entry.txHash !== txHash || + entry.kind !== LIGHTER_TX_TYPE_MODIFY_ORDER, + ); + if (entries.length !== doc.entries.length || consumed) { + await this.#writeNonceLedger( + accountIndex, + { + ...doc, + entries, + consumedFloor: consumed + ? Math.max(doc.consumedFloor, nonce + 1) + : doc.consumedFloor, + }, + record.apiKeyIndex, + ); + this.#assertSession(generation); + } + if (!consumed && nonce >= doc.consumedFloor) { + this.#releaseNonceReservation( + accountIndex, + nonce, + record.apiKeyIndex, + ); + } + }, + record.apiKeyIndex, + ); + }; + const reconcile = async ( + record: LighterNativeEditJournal, + ): Promise => { + if (record.status !== 'pending') { + return lighterNativeEditResult(record, params.newOrder); + } + if (record.phase === 'prepared' || record.phase === 'signed') { + // Account lock and durable attempted-before-transport boundary + // prove this interrupted attempt never reached transport. + record.status = 'failed'; + record.resolution = 'unsent'; + await save(record); + await retire(record, false); + return lighterNativeEditResult(record, params.newOrder); + } + let transaction: LighterTxLookupResponse | null; + try { + transaction = await this.#clientService.getTx(record.txHash ?? ''); + } catch { + this.#assertSession(generation); + if (record.resolution !== 'executed') { + return lighterNativeEditResult(record, params.newOrder); + } + transaction = null; + } + this.#assertSession(generation); + const row = await exactRow(record.original.clientOrderId); + if (row) { + record.observation = observeLighterNativeEdit(row, record); + } + if (transaction !== null) { + const matches = + typeof transaction.hash === 'string' && + transaction.hash.toLowerCase().replace(/^0x/u, '') === + record.txHash?.toLowerCase().replace(/^0x/u, '') && + transaction.accountIndex === accountIndex && + transaction.apiKeyIndex === record.apiKeyIndex && + transaction.nonce === record.nonce; + if (!matches) { + throw new Error( + 'Lighter native edit transaction identity mismatch', + ); + } + const outcome = getLighterTransactionOutcome(transaction.status); + if (outcome === 'failed') { + record.status = 'failed'; + record.resolution = 'failed'; + } else if (outcome === 'executed') { + record.resolution = 'executed'; + } + } else if ( + record.phase !== 'accepted' && + record.resolution !== 'executed' && + Date.now() > + (record.expiresAt ?? Number.MAX_SAFE_INTEGER) + + LIGHTER_TX_EXPIRY_SLACK_MS && + row + ) { + // Confirmed exact-hash absence after expiry is retry-safe only + // without prior acceptance/execution. The target is reread too. + const ledger = await this.#readNonceLedger( + accountIndex, + record.apiKeyIndex, + ); + this.#assertSession(generation); + // A durable consumed floor may retain an acceptance whose edit + // journal update failed. Never turn that evidence into absence. + if (ledger.consumedFloor <= (record.nonce ?? -1)) { + record.status = 'failed'; + record.resolution = 'expired'; + } + } + // An earlier exact execution proof survives later lookup loss. Its + // order obligation still needs a fresh exact observation. + if (record.resolution === 'executed' && row && record.observation) { + const status = adaptOrderStatus(row.status); + if (['filled', 'canceled'].includes(status)) { + record.status = 'terminal'; + } else if ( + status === 'open' && + new BigNumber(row.price).eq(record.intent.price) && + new BigNumber(row.initialBaseAmount).eq(record.intent.size) && + new BigNumber(row.remainingBaseAmount).eq(record.intent.size) && + new BigNumber(record.observation.filledSize).isZero() + ) { + record.status = 'settled'; + } + } + await save(record); + if ( + record.resolution === 'executed' || + record.resolution === 'failed' + ) { + await retire(record, true); + } else if (record.resolution === 'expired') { + await retire(record, false); + } + return lighterNativeEditResult(record, params.newOrder); + }; + if ( + journal?.status === 'pending' || + journal?.status === 'terminal' || + (journal?.status === 'settled' && + lighterNativeEditResult(journal, params.newOrder).success) + ) { + const retained = journal; + return await this.#withVenueWriteLock( + accountIndex, + async () => { + throw new Error('Lighter native edit recovery cannot dispatch'); + }, + generation, + retained.apiKeyIndex, + async () => ({ result: await reconcile(retained) }), + ); + } + const preflight = async (): Promise<{ + row: LighterEditableOrder; + intent: LighterNativeEditJournal['intent']; + }> => { + const row = await exactRow(); + if (!row) { + throw new Error( + 'Lighter native edit target is not an active exact order', + ); + } + const markets = await this.#clientService.getOrderBooks(true); + this.#assertSession(generation); + const matches = markets.filter( + (market) => + market.marketId === row.marketIndex && + market.symbol === params.newOrder.symbol, + ); + if (matches.length !== 1) { + throw new Error( + 'Lighter native edit market identity is unavailable', + ); + } + const intent = prepareLighterNativeEdit( + params.newOrder, + row, + matches[0], + accountIndex, + ); + const scale = await this.#readScaleGroups( + this.#scaleKey(accountIndex), + accountIndex, + ); + const attached = await this.#readAttachedGroups( + this.#attachedKey(accountIndex), + ); + const protection = await this.#readManagedTpsl( + `${scope.wallet}:${accountIndex}:${authority.apiKeyIndex}:${intent.symbol}`, + ); + const chase = await this.#chaseService.hasRecordedChild( + this.#chaseOwner(accountIndex), + String(row.clientOrderIndex), + { assertCurrent: () => this.#assertSession(generation) }, + ); + this.#assertSession(generation); + if ( + chase || + protection.some( + (order) => + order.clientId === String(row.clientOrderIndex) || + order.orderId === orderId, + ) || + scale.some((group) => + group.rungs.some( + (rung) => rung.clientOrderId === row.clientOrderIndex, + ), + ) || + attached.some((group) => + group.orders.some( + (order) => + order[0] === row.marketIndex && + order[1] === row.clientOrderIndex, + ), + ) + ) { + throw new Error( + 'Lighter native edit cannot modify a recorded strategy or protection child', + ); + } + return { row, intent }; + }; + await preflight(); + await this.#ensureSignerReady(); + this.#assertSession(generation); + if (this.#apiKeyIndex !== authority.apiKeyIndex) { + throw new Error( + 'Lighter native edit signing key changed after preflight', + ); + } + try { + return await this.#withVenueWriteLock( + accountIndex, + async (nextNonce, submit) => { + const { row, intent } = await preflight(); + const record: LighterNativeEditJournal = { + version: 1, + wallet: scope.wallet, + network: scope.network, + accountIndex, + apiKeyIndex: authority.apiKeyIndex, + startedAt: Date.now(), + original: lighterNativeEditTarget(row), + intent, + phase: 'prepared', + status: 'pending', + }; + await save(record); + const nonce = await nextNonce(); + const tuple: LighterSignModifyOrderWireParams = [ + accountIndex, + intent.marketIndex, + orderId, + intent.sizeInt, + intent.priceInt, + 0, + nonce, + ]; + const startedAt = Date.now(); + const signed = await this.#getSignerBridge().execute({ + function: '_signModifyOrder', + params: tuple, + }); + this.#assertSession(generation); + const decoded = decodeLighterModifyOrder( + signed, + tuple, + record.apiKeyIndex, + startedAt, + Date.now(), + ); + Object.assign(record, { + phase: 'signed', + nonce, + txHash: decoded.txHash, + expiresAt: decoded.expiresAt, + }); + await save(record); + await submit( + LIGHTER_TX_TYPE_MODIFY_ORDER, + decoded.txInfo, + undefined, + { + txHash: decoded.txHash, + expiresAt: decoded.expiresAt, + intent: `editOrder:${intent.symbol}:${orderId}`, + owner: key, + beforeDispatch: async () => { + const finalTarget = await preflight(); + if ( + JSON.stringify( + lighterNativeEditTarget(finalTarget.row), + ) !== JSON.stringify(record.original) || + JSON.stringify(finalTarget.intent) !== + JSON.stringify(record.intent) + ) { + throw new Error( + 'Lighter native edit target changed before dispatch', + ); + } + record.phase = 'attempted'; + await save(record); + }, + onNotDispatched: async () => { + record.status = 'failed'; + record.resolution = 'unsent'; + await save(record); + }, + }, + ); + record.phase = 'accepted'; + await save(record); + // A normal accepted edit can precede transaction or order indexer + // visibility. Poll only its exact durable identity; replayed + // pending edits above remain a single read-only reconciliation. + let result = await reconcile(record); + this.#assertSession(generation); + for ( + let attempt = 1; + attempt < LIGHTER_EDIT_SETTLE_ATTEMPTS && + result.orderEdit?.status === 'pending'; + attempt += 1 + ) { + await new Promise((resolve) => + setTimeout(resolve, LIGHTER_EDIT_SETTLE_POLL_MS), + ); + this.#assertSession(generation); + result = await reconcile(record); + this.#assertSession(generation); + } + return result; + }, + generation, + authority.apiKeyIndex, + async () => { + const current = await read(); + if (current?.status === 'pending') { + return { result: await reconcile(current) }; + } + // Inspecting this order must not bypass other-slot quarantine. + for ( + let slot = LIGHTER_MIN_TRADING_API_KEY_INDEX; + slot <= LIGHTER_MAX_TRADING_API_KEY_INDEX; + slot += 1 + ) { + if (slot !== authority.apiKeyIndex) { + await this.#resolveNonceLedger(accountIndex, slot); + } + } + }, + ); + } catch { + // Project only retained intent and observations. Do not leak signer + // or transport failures, or mutate a cancelled issuing session. + if (journal) { + return { + ...lighterNativeEditResult(journal, params.newOrder), + success: false, + }; + } + throw new Error('Lighter native edit could not dispatch'); + } + }); + } catch { + return { + success: false, + error: + 'Lighter native edit refused: exact ordinary resting order, supported fields and original read authority are required', 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 @@ -5846,20 +14197,136 @@ export class LighterProvider implements PerpsProvider { async updatePositionTPSL( params: UpdatePositionTPSLParams, ): Promise { + return this.#updatePositionTPSL(params); + } + + /** + * Submit a separately selected protection intent for an exact durable source. + * Reconcile original-slot attempts, then use a matching current registered key. + * + * @param params - New protection intent and opaque source identity. + * @returns Settled successor result; failure retains the source obligation. + */ + async resolveRecoveryProtection( + params: ResolveRecoveryProtectionParams, + ): Promise { + const result = await this.#resolveRecoveryProtection(params); + return { + ...result, + status: result.success ? 'settled' : 'unresolved', + providerId: 'lighter', + }; + } + + readonly #resolveRecoveryProtection = async ( + params: ResolveRecoveryProtectionParams, + ): 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. + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + this.#assertSession(generation); + if (!params.recoveryId.startsWith('lighter-protection:')) { + throw new Error('Invalid Lighter recovery source identity'); + } + const parsed: unknown = JSON.parse( + params.recoveryId.slice('lighter-protection:'.length), + ); if ( + !Array.isArray(parsed) || + parsed.length !== 6 || + parsed[0] !== (this.#isTestnet ? 'testnet' : 'mainnet') || + parsed[1] !== this.#boundAddress || + !Number.isSafeInteger(parsed[2]) || + !Number.isSafeInteger(parsed[3]) || + parsed[3] < LIGHTER_MIN_TRADING_API_KEY_INDEX || + parsed[3] > LIGHTER_MAX_TRADING_API_KEY_INDEX || + parsed[4] !== params.symbol || + typeof parsed[5] !== 'string' + ) { + throw new Error('Invalid Lighter recovery source scope'); + } + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + if (accountIndex !== parsed[2]) { + throw new Error('Lighter recovery source account changed'); + } + const slot = parsed[3] as number; + const settlementKey = `${this.#boundAddress}:${accountIndex}:${slot}:${params.symbol}`; + const operationId = parsed[5]; + const manual = await this.#loadTpslManualRecovery(settlementKey); + this.#assertSession(generation); + const journal = await this.#loadTpslJournal(settlementKey); + this.#assertSession(generation); + const successor = await this.#loadRecoverySuccessor( + settlementKey, + operationId, + ); + this.#assertSession(generation); + if (successor?.state === 'settled') { + await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}`, + async () => this.#finalizeRecoverySuccessor(successor, generation), + ); + this.#assertSession(generation); + return { success: true }; + } + if ((manual?.operationId ?? journal?.operationId) !== operationId) { + throw new Error('Lighter recovery source operation changed'); + } + return this.#updatePositionTPSL(params, { + settlementKey, + operationId, + slot, + generation, + }); + } catch (error) { + return { + success: false, + error: ensureError(error, 'LighterProvider.resolveRecoveryProtection') + .message, + }; + } + }; + + readonly #updatePositionTPSL = async ( + params: UpdatePositionTPSLParams, + sourceRecovery?: { + settlementKey: string; + operationId: string; + slot: number; + generation: number; + }, + ): Promise => { + let positionProtection: OrderResult['positionProtection']; + let expectedPosition = + params.expectedPosition === undefined + ? undefined + : { ...params.expectedPosition }; + try { + if (sourceRecovery) { + this.#assertSession(sourceRecovery.generation); + } + const partialRequested = params.takeProfitSize !== undefined || - params.stopLossSize !== undefined + params.stopLossSize !== undefined; + if ( + params.partialPairLinkage !== undefined && + (!partialRequested || + !params.takeProfitPrice || + !params.stopLossPrice || + !['equal-quantity-oco', 'independent'].includes( + params.partialPairLinkage, + )) ) { - return { - success: false, - error: - 'Lighter TP/SL covers the full position: partial takeProfitSize/stopLossSize are not supported', - }; + throw new Error( + 'Partial pair linkage requires a supported explicitly sized TP/SL pair', + ); + } + if ( + (params.takeProfitSize !== undefined && !params.takeProfitPrice) || + (params.stopLossSize !== undefined && !params.stopLossPrice) + ) { + throw new Error('A partial protection size requires its trigger price'); } this.#ensureSessionBinding(); const generationAtIntent = this.#sessionGeneration; @@ -5875,16 +14342,41 @@ export class LighterProvider implements PerpsProvider { // 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 positions = await this.#getPositions(partialRequested); const position = positions.find( (entry) => entry.symbol === params.symbol, ); - if (!position) { - return { - success: false, - error: `No open Lighter position for ${params.symbol}`, + this.#assertSession(generationAtIntent); + assertExpectedPosition(expectedPosition, position); + if (partialRequested && position && expectedPosition === undefined) { + expectedPosition = { + size: position.size, + entryPrice: position.entryPrice, }; } + let wantsReplacement = + Boolean(params.takeProfitPrice) || Boolean(params.stopLossPrice); + if (!position && (!sourceRecovery || wantsReplacement)) { + const previous = sourceRecovery + ? await this.#loadRecoverySuccessor( + sourceRecovery.settlementKey, + sourceRecovery.operationId, + ) + : null; + this.#assertSession(generationAtIntent); + // A prior dispatched intent may have completed by filling the position. + // Reconcile it before rejecting a new replacement on an empty position. + if ( + !previous || + (previous.state !== 'pending' && + previous.retainedReplacementGroups === undefined) + ) { + 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 @@ -5892,15 +14384,17 @@ export class LighterProvider implements PerpsProvider { // 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 partialIntent: PartialTpslIntent | undefined; let singleOrderPayload: LighterCreateOrderWireParams | null = null; let groupedOrderPayload: LighterGroupedOrderWireParams | null = null; let createdClientIds: number[] = []; - let createdIdsNeedingFinalCheck: number[] = []; + let childOrderIds: string[] | undefined = wantsReplacement + ? undefined + : []; + const createdIdsNeedingFinalCheck: number[] = []; let preflightPositionWireSize: number | null = null; let preflightPositionSign: 1 | -1 | null = null; - if (wantsReplacement) { + if (wantsReplacement && position) { // 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); @@ -5977,6 +14471,58 @@ export class LighterProvider implements PerpsProvider { // 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. + const partialSizes = validatedOrders.map((entry) => { + const raw = + entry.orderType === LIGHTER_ORDER_TYPE_TAKE_PROFIT + ? params.takeProfitSize + : params.stopLossSize; + if (!partialRequested || raw === undefined) { + return params.partialPairLinkage === 'independent' + ? new BigNumber(position.size) + .abs() + .shiftedBy(market.supportedSizeDecimals) + .integerValue(BigNumber.ROUND_DOWN) + .toNumber() + : sizeInt; + } + // Preserve decimal intent through the bounds check and downward grid + // normalization; binary conversion can widen a requested quantity. + const value = new BigNumber(raw); + const positionAmount = new BigNumber(position.size).abs(); + if ( + !/^(?:\d+(?:\.\d*)?|\.\d+)$/u.test(raw) || + !value.isFinite() || + !value.gt(0) || + !positionAmount.isFinite() || + value.gt(positionAmount) + ) { + throw new Error( + 'Partial protection quantity must be positive and no greater than the position', + ); + } + const wire = value + .shiftedBy(market.supportedSizeDecimals) + .integerValue(BigNumber.ROUND_DOWN); + if (wire.lt(1) || wire.gt(Number.MAX_SAFE_INTEGER)) { + throw new Error( + 'Partial protection quantity is outside the size grid', + ); + } + const normalized = wire.toNumber(); + return normalized; + }); + if ( + partialRequested && + params.partialPairLinkage !== 'independent' && + partialSizes.length === 2 && + (params.takeProfitSize === undefined || + params.stopLossSize === undefined || + partialSizes[0] !== partialSizes[1]) + ) { + throw new Error( + 'Partial OCO protection requires equal normalized quantities', + ); + } const wireOrders = validatedOrders.map( (entry, index): LighterCreateOrderWireParams => { const clientOrderIndex = clientOrderIds[index]; @@ -5988,7 +14534,7 @@ export class LighterProvider implements PerpsProvider { return [ market.marketId, clientOrderIndex, - String(sizeInt), + String(partialSizes[index]), String(entry.execInt), isAsk, entry.orderType, @@ -6001,6 +14547,60 @@ export class LighterProvider implements PerpsProvider { ]; }, ); + if (partialRequested) { + partialIntent = { + ...(params.partialPairLinkage === 'independent' + ? { + version: 2 as const, + linkage: 'independent' as const, + positionSize: new BigNumber(position.size).abs().toFixed(), + requestedSizes: [ + params.takeProfitSize ?? null, + params.stopLossSize ?? null, + ], + } + : { + version: 1 as const, + linkage: + wireOrders.length === 2 + ? ('oco' as const) + : ('single' as const), + }), + positionSign: isLong ? 1 : -1, + positionWireSize: + params.partialPairLinkage === 'independent' + ? new BigNumber(position.size) + .abs() + .shiftedBy(market.supportedSizeDecimals) + .integerValue(BigNumber.ROUND_DOWN) + .toNumber() + : sizeInt, + sizeDecimals: market.supportedSizeDecimals, + orders: wireOrders, + }; + if (!isPartialTpslIntent(partialIntent)) { + throw new Error('Invalid fixed partial protection intent'); + } + } + if (partialIntent?.version === 2) { + positionProtection = { + linkage: partialIntent.linkage, + legs: wireOrders.map((order) => ({ + role: + order[5] === LIGHTER_ORDER_TYPE_TAKE_PROFIT + ? 'take-profit' + : 'stop-loss', + requestedSize: + order[5] === LIGHTER_ORDER_TYPE_TAKE_PROFIT + ? params.takeProfitSize + : params.stopLossSize, + normalizedSize: new BigNumber(order[2]) + .shiftedBy(-market.supportedSizeDecimals) + .toFixed(), + status: 'unknown', + })), + }; + } const [first, second] = wireOrders; if (first === undefined) { throw new Error('Lighter TP/SL preflight produced no trigger order'); @@ -6016,15 +14616,32 @@ export class LighterProvider implements PerpsProvider { // 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(); + if (!sourceRecovery) { + await this.#ensureSignerReady(); + } const accountIndex = await this.#ensureAccountIndex(); this.#assertSession(generationAtIntent); + const recoveryAuthority = sourceRecovery + ? await withProcessMutex( + `lighterVenueWrite:${this.#isTestnet ? 'testnet' : 'mainnet'}:${accountIndex}`, + async () => + this.#getRecoveryReadToken( + accountIndex, + generationAtIntent, + this.#readyApiKeyIndex ?? undefined, + ), + ) + : undefined; + 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(); + const authToken = + recoveryAuthority?.token ?? (await this.#getAuthToken()); this.#assertSession(generationAtIntent); + const successorSlot = recoveryAuthority?.apiKeyIndex ?? this.#apiKeyIndex; + let selectedSuccessor: TpslRecoverySuccessor | null = null; // 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, @@ -6032,7 +14649,7 @@ export class LighterProvider implements PerpsProvider { // 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}`; + const settlementKey = `${this.#boundAddress ?? 'unbound'}:${accountIndex}:${successorSlot}:${params.symbol}`; // The ENTIRE snapshot -> create -> cancel lifecycle runs as ONE // serialized transition on the account's write chain. Two concurrent @@ -6045,6 +14662,105 @@ export class LighterProvider implements PerpsProvider { await this.#withVenueWriteLock( accountIndex, async (nextNonce, submit) => { + if (recoveryAuthority) { + if ( + this.#readyApiKeyIndex !== null && + this.#readyApiKeyIndex !== successorSlot + ) { + throw new Error( + 'Lighter recovery signing key changed while queued', + ); + } + const registrations = + await this.#clientService.getApiKeys(accountIndex); + this.#assertSession(generationAtIntent); + const matching = registrations.apiKeys.filter( + (entry) => + entry.accountIndex === accountIndex && + entry.apiKeyIndex === successorSlot && + entry.publicKey.replace(/^0x/u, '').toLowerCase() === + recoveryAuthority.publicKey.replace(/^0x/u, '').toLowerCase(), + ); + if (matching.length !== 1) { + throw new Error( + 'Lighter recovery successor registered key changed', + ); + } + const bridge = this.#getSignerBridge(); + const created = await bridge.createClient({ + chainId: getLighterChainId(this.#clientService.network), + accountIndex, + apiKeyIndex: successorSlot, + nonce: await nextNonce(), + walletAddress: this.#boundAddress ?? undefined, + }); + this.#assertSession(generationAtIntent); + if ( + !created.success || + created.error || + created.pk?.replace(/^0x/u, '').toLowerCase() !== + recoveryAuthority.publicKey.replace(/^0x/u, '').toLowerCase() + ) { + throw new Error('Lighter recovery successor local key changed'); + } + this.#venuePublicKey = recoveryAuthority.publicKey; + this.#authToken = null; + this.#signerIdentity = `${this.#clientService.network}:${accountIndex}:${successorSlot}`; + this.#signerRecreateParams = { + chainId: getLighterChainId(this.#clientService.network), + accountIndex, + }; + bridgeClientOwners.set( + this.#rawSignerBridge(), + this.#signerIdentity, + ); + } + const assertLiveExpected = async (): Promise => { + if (sourceRecovery) { + this.#assertSession(generationAtIntent); + const manualSource = await this.#loadTpslManualRecovery( + sourceRecovery.settlementKey, + ); + this.#assertSession(generationAtIntent); + const journalSource = await this.#loadTpslJournal( + sourceRecovery.settlementKey, + ); + this.#assertSession(generationAtIntent); + if ( + manualSource?.operationId !== sourceRecovery.operationId && + journalSource?.operationId !== sourceRecovery.operationId + ) { + throw new Error('Lighter recovery source operation changed'); + } + } + if (expectedPosition === undefined) { + return; + } + this.#assertSession(generationAtIntent); + const account = + await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generationAtIntent); + const currentPositions = + Array.isArray(account.accounts) && + account.accounts.length === 1 && + account.accounts[0]?.index === accountIndex && + Array.isArray(account.accounts[0].positions) + ? account.accounts[0].positions + : undefined; + const current = currentPositions?.find( + (entry) => entry.symbol === params.symbol, + ); + assertExpectedPosition( + expectedPosition, + current && (current.sign === 1 || current.sign === -1) + ? { + size: `${current.sign === -1 ? '-' : ''}${current.position}`, + entryPrice: current.avgEntryPrice, + } + : undefined, + ); + }; + await assertLiveExpected(); // 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 @@ -6056,17 +14772,551 @@ export class LighterProvider implements PerpsProvider { 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, - ); + if ( + !Array.isArray(response.orders) || + (sourceRecovery !== undefined && + response.orders.some( + (row) => row.ownerAccountIndex !== accountIndex, + )) + ) { + throw new Error( + 'Invalid Lighter protection recovery order envelope', + ); + } + 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, + ); + + const collectRetainedReceipt = async ( + groups: number[][], + ): Promise => { + // The current preflight IDs are unsent; they cannot describe an earlier intent. + positionProtection = undefined; + childOrderIds = undefined; + const ids = groups.flat(); + if (ids.length === 0) { + return; + } + const active = await readActiveRaw(); + const missing = ids.filter( + (id) => !active.some((row) => row.clientOrderIndex === id), + ); + const inactive = + missing.length > 0 ? await readInactiveFor(missing) : []; + this.#assertSession(generationAtIntent); + const receipt: string[] = []; + for (const id of ids) { + const activeRows = active.filter( + (row) => row.clientOrderIndex === id, + ); + const rows = + activeRows.length > 0 + ? activeRows + : inactive.filter((row) => row.clientOrderIndex === id); + const [row] = rows; + if ( + rows.length !== 1 || + !row || + row.ownerAccountIndex !== accountIndex || + row.marketIndex !== market.marketId || + !Number.isSafeInteger(row.orderIndex) || + row.orderIndex <= 0 || + receipt.includes(String(row.orderIndex)) + ) { + return; + } + receipt.push(String(row.orderIndex)); + } + childOrderIds = receipt; + }; + const replacementGroups = (journal: TpslJournalState): number[][] => + journal.retainedReplacementGroups ?? + journal.attempts + .filter( + (attempt): attempt is TpslCreateAttempt => + attempt.kind === 'create' && + attempt.role === 'replacement' && + attempt.neverLanded !== true && + getLighterTransactionOutcome(attempt.terminalStatus) !== + 'failed', + ) + .map((attempt) => attempt.clientIds); + + if (sourceRecovery) { + selectedSuccessor = await this.#loadRecoverySuccessor( + sourceRecovery.settlementKey, + sourceRecovery.operationId, + ); + this.#assertSession(generationAtIntent); + if (selectedSuccessor?.state === 'settled') { + childOrderIds = undefined; + positionProtection = undefined; + return; + } + if (selectedSuccessor?.state === 'failed') { + // A crash may leave the exact retired journal after its failure + // marker. Complete cleanup before preparing the next intent. + const failedJournal = await this.#loadTpslJournal( + selectedSuccessor.successorSettlementKey, + ); + this.#assertSession(generationAtIntent); + if (failedJournal === null) { + if ( + !(await this.#clearTpslJournal( + selectedSuccessor.successorSettlementKey, + null, + )) + ) { + throw new Error('Lighter failed successor operation changed'); + } + this.#assertSession(generationAtIntent); + } else if ( + failedJournal.operationId === + selectedSuccessor.successorOperationId + ) { + if ( + !(await this.#clearTpslJournal( + selectedSuccessor.successorSettlementKey, + failedJournal.operationId, + await readActiveRaw(), + )) + ) { + throw new Error('Lighter failed successor operation changed'); + } + this.#assertSession(generationAtIntent); + } + } + if (selectedSuccessor && selectedSuccessor.state !== 'failed') { + const pendingSuccessor = await this.#loadTpslJournal( + selectedSuccessor.successorSettlementKey, + ); + this.#assertSession(generationAtIntent); + if ( + pendingSuccessor?.operationId === + selectedSuccessor.successorOperationId + ) { + if ( + selectedSuccessor.successorSettlementKey === settlementKey + ) { + const settled = await this.#settleTpslObligation({ + settlementKey, + symbol: params.symbol, + journalEntry: pendingSuccessor, + market, + accountIndex, + authToken, + generation: generationAtIntent, + readActiveRaw, + readInactiveFor, + nextNonce, + submit, + }); + const after = await this.#loadRecoverySuccessor( + sourceRecovery.settlementKey, + sourceRecovery.operationId, + ); + this.#assertSession(generationAtIntent); + if (settled && after?.state === 'settled') { + if ( + pendingSuccessor.intent === 'remove' && + pendingSuccessor.retainedReplacementGroups === undefined + ) { + childOrderIds = []; + positionProtection = undefined; + } else { + await collectRetainedReceipt( + replacementGroups(pendingSuccessor), + ); + } + return; + } + throw new Error( + 'Lighter recovery successor remains unresolved; review its durable outcome before another intent', + ); + } else { + const oldSlot = Number( + selectedSuccessor.successorSettlementKey.split(':')[2], + ); + if ( + !Number.isSafeInteger(oldSlot) || + oldSlot < LIGHTER_MIN_TRADING_API_KEY_INDEX || + oldSlot > LIGHTER_MAX_TRADING_API_KEY_INDEX || + (await this.#reconcilePriorTpsl( + readActiveRaw, + readInactiveFor, + accountIndex, + pendingSuccessor, + oldSlot, + )) !== 'resolved' + ) { + throw new Error( + 'Lighter previous successor submissions remain unresolved', + ); + } + this.#assertSession(generationAtIntent); + await this.#verifyRetainedRecoveryCoverage( + pendingSuccessor, + readActiveRaw, + readInactiveFor, + ); + await this.#discardManagedTpslIds( + selectedSuccessor.successorSettlementKey, + pendingSuccessor.attempts.flatMap((attempt) => + attempt.kind === 'create' && + (attempt.neverLanded === true || + getLighterTransactionOutcome(attempt.terminalStatus) === + 'failed') + ? attempt.clientIds.map(String) + : [], + ), + ); + this.#assertSession(generationAtIntent); + const groups = + pendingSuccessor.retainedReplacementGroups ?? + pendingSuccessor.attempts + .filter( + (attempt): attempt is TpslCreateAttempt => + attempt.kind === 'create' && + attempt.role === 'replacement' && + attempt.neverLanded !== true && + getLighterTransactionOutcome( + attempt.terminalStatus, + ) !== 'failed', + ) + .map((attempt) => attempt.clientIds); + const coverage = + groups.length === 0 + ? { outcome: 'created-terminal-failed' as const } + : await this.#awaitTpslVisibility( + readActiveRaw, + readInactiveFor, + { + createdClientIds: groups.flat(), + cancelledOrderIds: [], + }, + { createdGroups: groups }, + ); + if (coverage.outcome === 'timeout') { + throw new Error( + 'Lighter previous successor coverage remains unresolved', + ); + } + const active = await readActiveRaw(); + const priorIds = pendingSuccessor.priorTriggers.map( + (prior) => prior.orderId, + ); + const priorGone = priorIds.every( + (id) => + !active.some((row) => String(row.orderIndex) === id), + ); + const alreadyWon = + pendingSuccessor.intent === 'remove' || + (groups.length > 0 && + coverage.outcome === 'settled' && + (pendingSuccessor.partialIntent?.version !== 2 || + pendingSuccessor.partialIntent.orders.every((order) => + groups.flat().includes(order[1]), + ))); + if (alreadyWon && priorGone) { + if ( + pendingSuccessor.intent === 'remove' && + pendingSuccessor.retainedReplacementGroups === undefined + ) { + childOrderIds = []; + positionProtection = undefined; + } else { + await collectRetainedReceipt(groups); + } + await this.#finishRecoverySuccessor( + pendingSuccessor, + 'settled', + active, + generationAtIntent, + ); + return; + } + // Persist the original created coverage before retiring its + // journal. Continuing with the current key only cancels its + // exact prior orders; it must never create the same intent again. + selectedSuccessor = { + ...selectedSuccessor, + state: 'prepared', + retainedReplacementGroups: alreadyWon ? groups : undefined, + ownedOrderIds: alreadyWon + ? priorIds + : [ + ...new Set([ + ...selectedSuccessor.ownedOrderIds, + ...active + .filter((row) => + groups.flat().includes(row.clientOrderIndex), + ) + .map((row) => String(row.orderIndex)), + ]), + ], + }; + await this.#persistRecoverySuccessor( + selectedSuccessor, + generationAtIntent, + ); + if ( + !(await this.#clearTpslJournal( + selectedSuccessor.successorSettlementKey, + pendingSuccessor.operationId, + active, + )) + ) { + throw new Error( + 'Lighter previous successor operation changed', + ); + } + this.#assertSession(generationAtIntent); + } + } + if (selectedSuccessor.state === 'pending') { + throw new Error( + 'Lighter recovery successor journal is unavailable', + ); + } + } + if (selectedSuccessor?.retainedReplacementGroups !== undefined) { + await collectRetainedReceipt( + selectedSuccessor.retainedReplacementGroups, + ); + wantsReplacement = false; + } + if (!position && wantsReplacement) { + throw new Error(`No open Lighter position for ${params.symbol}`); + } + const sourceJournal = await this.#loadTpslJournal( + sourceRecovery.settlementKey, + ); + this.#assertSession(generationAtIntent); + const manualSource = await this.#loadTpslManualRecovery( + sourceRecovery.settlementKey, + ); + this.#assertSession(generationAtIntent); + if ( + sourceJournal && + sourceJournal.operationId !== sourceRecovery.operationId + ) { + throw new Error( + 'Lighter recovery source journal operation changed', + ); + } + if ( + sourceJournal && + (await this.#reconcilePriorTpsl( + readActiveRaw, + readInactiveFor, + accountIndex, + sourceJournal, + sourceRecovery.slot, + )) !== 'resolved' + ) { + throw new Error( + 'Lighter original protection submissions remain unresolved', + ); + } + this.#assertSession(generationAtIntent); + const sourceActive = await readActiveRaw(); + const ownedOrderIds = [ + ...new Set([ + ...(selectedSuccessor?.ownedOrderIds ?? []), + ...(manualSource?.survivingOrderIds ?? []), + ...(manualSource?.priorTriggers.map((prior) => prior.orderId) ?? + []), + ...(sourceJournal?.priorTriggers.map( + (prior) => prior.orderId, + ) ?? []), + ...sourceActive + .filter((row) => + sourceJournal?.attempts.some( + (attempt) => + attempt.kind === 'create' && + attempt.clientIds.includes(row.clientOrderIndex), + ), + ) + .map((row) => String(row.orderIndex)), + ]), + ]; + selectedSuccessor = { + version: 1, + sourceSettlementKey: sourceRecovery.settlementKey, + sourceOperationId: sourceRecovery.operationId, + successorSettlementKey: settlementKey, + successorOperationId: + selectedSuccessor?.state === 'prepared' + ? selectedSuccessor.successorOperationId + : `op-${Date.now().toString(36)}-${(this.#tpslOperationCounter += 1).toString(36)}-${randomIdSuffix()}`, + state: 'prepared', + ownedOrderIds, + retainedReplacementGroups: + selectedSuccessor?.retainedReplacementGroups, + }; + await this.#persistRecoverySuccessor( + selectedSuccessor, + generationAtIntent, + ); + await assertLiveExpected(); + if (!manualSource && sourceJournal) { + await this.#writeTpslManualRecovery({ + settlementKey: sourceRecovery.settlementKey, + symbol: params.symbol, + reason: + 'Explicit protection successor awaiting authoritative settlement', + partialIntent: sourceJournal.partialIntent, + priorIntent: sourceJournal.intent, + priorTriggers: sourceJournal.priorTriggers, + survivingOrderIds: ownedOrderIds, + operationId: sourceRecovery.operationId, + recordedAt: sourceJournal.recordedAt, + }); + this.#assertSession(generationAtIntent); + } + if ( + sourceJournal && + !(await this.#clearTpslJournal( + sourceRecovery.settlementKey, + sourceRecovery.operationId, + sourceActive, + )) + ) { + throw new Error( + 'Lighter recovery source operation changed during transfer', + ); + } + this.#assertSession(generationAtIntent); + } + if (!sourceRecovery) { + const sourceKeys = [ + ...new Set([ + ...(await this.#readTpslManualIndex()), + ...(await this.#readTpslJournalIndex()), + ]), + ]; + this.#assertSession(generationAtIntent); + for (const sourceKey of sourceKeys) { + const [sourceAddress, sourceAccount, , ...sourceSymbol] = + sourceKey.split(':'); + if ( + sourceAddress !== this.#boundAddress || + sourceAccount !== String(accountIndex) || + sourceSymbol.join(':') !== params.symbol + ) { + continue; + } + const manual = await this.#loadTpslManualRecovery(sourceKey); + this.#assertSession(generationAtIntent); + if (manual?.partialIntent) { + throw new Error( + 'Partial protection requires explicit recovery resolution by recovery ID', + ); + } + const sourceJournal = await this.#loadTpslJournal(sourceKey); + this.#assertSession(generationAtIntent); + if (sourceJournal?.partialIntent && sourceKey !== settlementKey) { + const transfer = await this.#loadRecoverySuccessor( + sourceKey, + sourceJournal.operationId, + ); + this.#assertSession(generationAtIntent); + if ( + sourceJournal.attempts.length === 0 && + sourceJournal.sourceRecoveryOperationId === undefined && + sourceJournal.sourceRecoverySettlementKey === undefined && + (!transfer || transfer.state === 'failed') + ) { + try { + const cleared = await this.#clearTpslJournal( + sourceKey, + sourceJournal.operationId, + ); + this.#assertSession(generationAtIntent); + if (cleared) { + continue; + } + } catch (cleanupError) { + this.#assertSession(generationAtIntent); + this.#deps.debugLogger.log( + '[LighterProvider] Older-slot protection cleanup remains pending', + { error: String(cleanupError) }, + ); + } + } + throw new Error( + 'Partial protection requires explicit recovery resolution by recovery ID', + ); + } + const sourceOperationId = + manual?.operationId ?? sourceJournal?.operationId; + if (!sourceOperationId) { + continue; + } + const transfer = await this.#loadRecoverySuccessor( + sourceKey, + sourceOperationId, + ); + this.#assertSession(generationAtIntent); + if ( + transfer && + transfer.state !== 'failed' && + transfer.state !== 'settled' + ) { + throw new Error( + 'Selected protection recovery is pending; resume its explicit successor', + ); + } + } + } + // An accepted create can leave the nonce ledger before its trigger + // is visible. Another slot may still own settlement for this symbol. + // Reconcile only its original-slot proof; never sign its follow-up + // under the selected key. Refuse while any attempt is unresolved. + const journalIndex = await this.#readTpslJournalIndex(); + this.#assertSession(generationAtIntent); + for (const previousKey of journalIndex) { + const [address, account, slot, ...symbol] = previousKey.split(':'); + if ( + previousKey === settlementKey || + address !== this.#boundAddress || + account !== String(accountIndex) || + symbol.join(':') !== params.symbol + ) { + continue; + } + const previousJournal = await this.#loadTpslJournal(previousKey); + this.#assertSession(generationAtIntent); + if ( + previousJournal && + !(await this.#settleTpslObligation({ + settlementKey: previousKey, + symbol: params.symbol, + journalEntry: previousJournal, + readOnlyApiKeyIndex: Number(slot), + market, + accountIndex, + authToken, + generation: generationAtIntent, + readActiveRaw, + readInactiveFor, + nextNonce, + submit, + })) + ) { + throw new Error( + `Lighter TP/SL settlement for ${params.symbol} is still settling under its original API key slot ${String(slot)}; retry after the venue shows its outcome`, + ); + } + } // 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 @@ -6082,6 +15332,9 @@ export class LighterProvider implements PerpsProvider { this.#tpslUnsettled.delete(settlementKey); } if (unsettled) { + const hadPartialAttempts = Boolean( + unsettled.partialIntent && unsettled.attempts.length > 0, + ); const resolved = await this.#settleTpslObligation({ settlementKey, symbol: params.symbol, @@ -6102,6 +15355,14 @@ export class LighterProvider implements PerpsProvider { `Lighter TP/SL settlement for ${params.symbol} is unresolved; refusing further protection changes until the venue reflects the previous update`, ); } + if (hadPartialAttempts) { + const parked = await this.#loadTpslManualRecovery(settlementKey); + throw new Error( + parked + ? 'Prior partial protection requires resolveRecoveryProtection with its exact pending recovery ID and fresh current-position intent' + : 'Prior partial protection settled; re-read orders before submitting a new intent', + ); + } // 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 @@ -6111,6 +15372,98 @@ export class LighterProvider implements PerpsProvider { } const rawOrders = await readActiveRaw(); + await this.#pruneManagedTpsl(settlementKey, rawOrders, async () => { + this.#assertSession(generationAtIntent); + const response = await this.#clientService.getInactiveOrders( + accountIndex, + authToken, + LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE, + undefined, + market.marketId, + ); + this.#assertSession(generationAtIntent); + if ( + response.orders.length > LIGHTER_TPSL_OWNERSHIP_HISTORY_PAGE_SIZE + ) { + throw new Error('Lighter ownership history exceeded one page'); + } + return response.orders.filter( + (row) => + row.ownerAccountIndex === accountIndex && + row.marketIndex === market.marketId, + ); + }); + const attachedGroups = await this.#readAttachedGroups( + this.#attachedKey(accountIndex), + ); + this.#assertSession(generationAtIntent); + const attachedClientIds = new Set( + attachedGroups.flatMap((group) => + group.orders.map((order) => String(order[1])), + ), + ); + const managed = await this.#readManagedTpsl(settlementKey); + this.#assertSession(generationAtIntent); + // The public preflight position read occurred before signer + // setup and write serialization. Re-read the raw venue position + // inside the held transition immediately before any create or + // cancel signature: an intervening fill or side flip would make + // the captured cover payload under-sized or wrong-sided. + const liveAccount = + await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generationAtIntent); + if ( + !Array.isArray(liveAccount.accounts) || + liveAccount.accounts.length !== 1 || + liveAccount.accounts[0]?.index !== accountIndex || + !Array.isArray(liveAccount.accounts[0].positions) + ) { + throw new Error( + 'Invalid Lighter account position envelope before TP/SL signing', + ); + } + const livePosition = liveAccount.accounts[0].positions.find( + (entry) => entry.symbol === params.symbol, + ); + const liveMagnitude = livePosition + ? parseStrictDecimal(livePosition.position) + : null; + let liveWireSize: number | null = null; + if ( + liveMagnitude !== null && + Number.isFinite(liveMagnitude) && + liveMagnitude > 0 && + (livePosition?.sign === 1 || livePosition?.sign === -1) + ) { + try { + liveWireSize = toSignerWireInteger( + liveMagnitude, + market.supportedSizeDecimals, + ); + } catch { + liveWireSize = null; + } + } + assertExpectedPosition( + expectedPosition, + livePosition && + (livePosition.sign === 1 || livePosition.sign === -1) + ? { + size: `${livePosition.sign === -1 ? '-' : ''}${livePosition.position}`, + entryPrice: livePosition.avgEntryPrice, + } + : undefined, + ); + if ( + wantsReplacement && + (liveWireSize === null || + liveWireSize !== preflightPositionWireSize || + livePosition?.sign !== preflightPositionSign) + ) { + throw new Error( + `Lighter position changed before TP/SL signing for ${params.symbol}; refresh and retry protection against the current position`, + ); + } const openOrders = rawOrders.map((order) => adaptOrderFromLighter( order, @@ -6118,14 +15471,40 @@ export class LighterProvider implements PerpsProvider { String(order.marketIndex), ), ); - const staleTriggers = openOrders.filter( - (order) => + const staleTriggers = openOrders.filter((order) => { + const raw = rawOrders.find( + (row) => String(row.orderIndex) === order.orderId, + ); + // Attached children belong to their opening group, not this position update. + if (raw && attachedClientIds.has(String(raw.clientOrderIndex))) { + return false; + } + // Core-owned IDs survive resizing, restart and key-slot recovery. + // Legacy unrecorded full-quantity closing trigger markets retain + // their historical protection contract, using the in-lock position. + // Independent partial and limit triggers are preserved. + const isManaged = + raw !== undefined && + managed.some( + (entry) => + entry.clientId === String(raw.clientOrderIndex) || + entry.orderId === order.orderId, + ); + const isSelected = + selectedSuccessor?.ownedOrderIds.includes(order.orderId) ?? false; + const isLegacyProtection = + sourceRecovery === undefined && + !partialRequested && + liveWireSize !== null && + order.side === (livePosition?.sign === 1 ? 'sell' : 'buy') && + parseFinitePositive(order.size) === liveMagnitude; + return ( order.symbol === params.symbol && order.reduceOnly && - (Boolean(order.orderType?.includes('stop')) || - Boolean(order.orderType?.includes('take')) || - order.isTrigger === true), - ); + (raw?.type === 'stop-loss' || raw?.type === 'take-profit') && + (sourceRecovery ? isSelected : isManaged || isLegacyProtection) + ); + }); // 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 @@ -6228,22 +15607,83 @@ export class LighterProvider implements PerpsProvider { // even when the response is lost), flips to accepted inside // onAccepted (pre-fence), and reconciliation disambiguates each // attempt individually via books + nonce. + await assertLiveExpected(); + let initialPhase: TpslJournalState['phase'] = partialIntent + ? 'cancelling' + : 'creating'; + if (!wantsReplacement && expectedPosition !== undefined) { + initialPhase = 'manual'; + } const journal: TpslJournalState = { + partialIntent, 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()}`, + operationId: + selectedSuccessor?.successorOperationId ?? + `op-${Date.now().toString(36)}-${(this.#tpslOperationCounter += 1).toString(36)}-${randomIdSuffix()}`, + sourceRecoveryOperationId: sourceRecovery?.operationId, + sourceRecoverySettlementKey: sourceRecovery?.settlementKey, + retainedReplacementGroups: + selectedSuccessor?.retainedReplacementGroups, createdAt: lifecycleBoundary, nextAttemptId: 1, intent: wantsReplacement ? 'replace' : 'remove', - phase: 'creating', + // A guard belongs only to this foreground session. Persist its + // non-resumable authority before the first dispatch, so a failed + // refusal write cannot reauthorize later recovery cancellations. + phase: initialPhase, priorGrouping, priorTriggers, }; const persistJournal = async (): Promise => { journal.recordedAt = Date.now(); await this.#persistTpslJournal(settlementKey, journal); + this.#assertSession(generationAtIntent); + if (selectedSuccessor) { + selectedSuccessor = { ...selectedSuccessor, state: 'pending' }; + await this.#persistRecoverySuccessor( + selectedSuccessor, + generationAtIntent, + ); + } + }; + const discardUnsentAttempt = async ( + attempt: TpslJournalState['attempts'][number], + ): Promise => { + this.#assertSession(generationAtIntent); + const remaining = journal.attempts.filter( + (entry) => entry.attemptId !== attempt.attemptId, + ); + if (attempt.kind === 'create') { + await this.#discardManagedTpslIds( + settlementKey, + attempt.clientIds.map(String), + ); + } + this.#assertSession(generationAtIntent); + if (remaining.length === 0) { + await this.#finishRecoverySuccessor( + journal, + 'failed', + journal.sourceRecoveryOperationId ? await readActiveRaw() : [], + generationAtIntent, + ); + await this.#clearTpslJournal(settlementKey, journal.operationId); + } else { + // Dispatched attempts still require reconciliation, but a + // refused removal no longer authorizes recovery to cancel + // the surviving protection against a changed position. + if (journal.intent === 'remove') { + journal.phase = 'manual'; + } + await this.#persistTpslJournal(settlementKey, { + ...journal, + attempts: remaining, + }); + } + journal.attempts = remaining; }; // Sign+journal+submit one tracked cancel (stale protection or a // rollback of a surviving replacement leg). @@ -6252,11 +15692,11 @@ export class LighterProvider implements PerpsProvider { 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.) + // Persist the phase before touching old protection; interruption + // from this point may require explicit manual recovery. journal.phase = 'cancelling'; } + await this.#assertRecoverySource(journal, generationAtIntent); const cancelNonce = await nextNonce(); const signedCancel = await this.#getSignerBridge().execute({ function: '_signCancelOrder', @@ -6291,280 +15731,589 @@ export class LighterProvider implements PerpsProvider { txHash: cancelIdentity.txHash, expiresAt: cancelIdentity.expiresAt, owner: journal.operationId, + ...(journal.intent === 'remove' || + sourceRecovery || + partialIntent + ? { + beforeDispatch: + sourceRecovery && !partialIntent + ? async (): Promise => + this.#assertRecoverySource( + journal, + generationAtIntent, + ) + : assertLiveExpected, + onNotDispatched: async (): Promise => + discardUnsentAttempt(cancelAttempt), + } + : {}), }, ); }; - // 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 && - (singleOrderPayload !== null || groupedOrderPayload !== null) - ) { - // The public preflight position read occurred before signer - // setup and write serialization. Re-read the raw venue position - // inside the held transition immediately before any create or - // cancel signature: an intervening fill or side flip would make - // the captured cover payload under-sized or wrong-sided. - this.#assertSession(generationAtIntent); - const liveAccount = - await this.#clientService.getAccountByIndex(accountIndex); + const observeProtection = async (): Promise => { + if (!positionProtection) { + return; + } this.#assertSession(generationAtIntent); - const livePosition = liveAccount.accounts[0]?.positions?.find( - (entry) => entry.symbol === params.symbol, + const active = await readActiveRaw(); + const missing = createdClientIds.filter( + (id) => + positionProtection?.legs.some( + (leg) => leg.clientOrderId === String(id), + ) && + !active.some( + (row) => String(row.clientOrderIndex) === String(id), + ), ); - const liveMagnitude = livePosition - ? parseStrictDecimal(livePosition.position) - : null; - let liveWireSize: number | null = null; - if ( - liveMagnitude !== null && - Number.isFinite(liveMagnitude) && - liveMagnitude > 0 && - (livePosition?.sign === 1 || livePosition?.sign === -1) - ) { - try { - liveWireSize = toSignerWireInteger( - liveMagnitude, - market.supportedSizeDecimals, - ); - } catch { - liveWireSize = null; + const inactive = + missing.length > 0 ? await readInactiveFor(missing) : []; + this.#assertSession(generationAtIntent); + for (const leg of positionProtection.legs) { + if (!leg.clientOrderId) { + continue; } - } - if ( - liveWireSize === null || - liveWireSize !== preflightPositionWireSize || - livePosition?.sign !== preflightPositionSign - ) { - throw new Error( - `Lighter position changed before TP/SL signing for ${params.symbol}; refresh and retry protection against the current position`, + leg.status = 'unknown'; + const activeRows = active.filter( + (row) => String(row.clientOrderIndex) === leg.clientOrderId, ); - } - const createNonce = await nextNonce(); - // A lone trigger is an ordinary CreateOrder (same wire - // layout); only a TP+SL pair uses the grouped OCO transaction. - let signed: LighterTxResult; - let isSingleTrigger = false; - if (singleOrderPayload === null) { - if (groupedOrderPayload === null) { - throw new Error('Lighter TP/SL preflight payload is missing'); + const rows = + activeRows.length > 0 + ? activeRows + : inactive.filter( + (row) => + String(row.clientOrderIndex) === leg.clientOrderId, + ); + const [row] = rows; + if ( + rows.length !== 1 || + !row || + row.ownerAccountIndex !== accountIndex || + row.marketIndex !== market.marketId || + !Number.isSafeInteger(row.orderIndex) || + row.orderIndex <= 0 + ) { + continue; + } + if ( + leg.orderId !== undefined && + leg.orderId !== String(row.orderIndex) + ) { + throw new Error('Lighter protection receipt identity changed'); + } + leg.orderId = String(row.orderIndex); + const remaining = new BigNumber(row.remainingBaseAmount); + const initial = new BigNumber(row.initialBaseAmount); + const status = row.status.toLowerCase(); + leg.status = 'unknown'; + if ( + activeRows.length > 0 && + remaining.isFinite() && + remaining.gt(0) && + remaining.lte(initial) + ) { + leg.status = remaining.lt(initial) + ? 'partially-filled' + : 'resting'; + } else if ( + (status === 'filled' || status === 'executed') && + remaining.eq(0) + ) { + leg.status = 'filled'; + } else if (status === 'canceled' || status === 'cancelled') { + leg.status = 'canceled'; + } else if (status === 'rejected') { + leg.status = 'rejected'; } - signed = await this.#getSignerBridge().execute({ - function: '_signCreateGroupedOrders', - params: [ - accountIndex, - LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER, - 2, - ...groupedOrderPayload, - createNonce, - ], - }); - } else { - isSingleTrigger = true; - signed = await this.#getSignerBridge().execute({ - function: '_signCreateOrder', - params: [accountIndex, ...singleOrderPayload, 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`, + try { + if (partialIntent) { + await persistJournal(); + for (const order of staleTriggers) { + await submitTrackedCancel(order.orderId, 'stale'); + } + // No replacement dispatch until exact cancellation transactions and books settle. + const reconciled = await this.#reconcilePriorTpsl( + readActiveRaw, + readInactiveFor, + accountIndex, + journal, + ); + const cancellation = await this.#awaitTpslVisibility( + readActiveRaw, + readInactiveFor, + { + createdClientIds: [], + cancelledOrderIds: staleTriggers.map( + (order) => order.orderId, + ), + }, ); + if ( + reconciled !== 'resolved' || + cancellation.outcome !== 'settled' + ) { + throw new Error( + 'Partial protection cancellation is unresolved; replacement was not sent', + ); + } + await assertLiveExpected(); } - 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', + + // Snapshot-sized protection retains create-before-cancel behavior. + // Fixed partial protection has already proven its old set cancelled. + if ( + wantsReplacement && + (singleOrderPayload !== null || groupedOrderPayload !== null) + ) { + const batches = + partialIntent?.version === 2 + ? partialIntent.orders.map((order) => ({ + single: order, + grouped: null, + clientIds: [order[1]], + })) + : [ + { + single: singleOrderPayload, + grouped: groupedOrderPayload, + clientIds: createdClientIds, + }, + ]; + const independentPair = partialIntent?.version === 2; + const receipt = positionProtection; + const allClientIds = createdClientIds; + for (const batch of batches) { + const assertCreateCurrent = async (): Promise => { + await assertLiveExpected(); + if (independentPair && batch !== batches[0]) { + await observeProtection(); + if (receipt?.legs[0].status !== 'resting') { + throw new Error( + 'Independent protection first leg changed; the stale sibling was not sent', + ); + } + } + }; + if (independentPair) { + await assertCreateCurrent(); + } + const createNonce = await nextNonce(); + // A lone trigger is an ordinary CreateOrder (same wire + // layout); only a TP+SL pair uses the grouped OCO transaction. + let signed: LighterTxResult; + let isSingleTrigger = false; + if (batch.single === null) { + if (batch.grouped === null) { + throw new Error( + 'Lighter TP/SL preflight payload is missing', ); } + signed = await this.#getSignerBridge().execute({ + function: '_signCreateGroupedOrders', + params: [ + accountIndex, + LIGHTER_GROUPING_ONE_CANCELS_THE_OTHER, + 2, + ...batch.grouped, + createNonce, + ], + }); + } else { + isSingleTrigger = true; + signed = await this.#getSignerBridge().execute({ + function: '_signCreateOrder', + params: [accountIndex, ...batch.single, createNonce], + }); + } + if (signed.error) { + throw new Error(signed.error); } - const rollback = await this.#awaitTpslVisibility( + // 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: [...batch.clientIds], + orderExpiries: requireSignedOrderExpiries( + signed, + batch.clientIds, + ), + 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, + beforeDispatch: assertCreateCurrent, + onDispatch: () => { + for (const clientId of batch.clientIds) { + const leg = + receipt?.legs[allClientIds.indexOf(clientId)]; + if (leg) { + leg.clientOrderId = String(clientId); + } + } + }, + onNotDispatched: async (): Promise => + discardUnsentAttempt(createAttempt), + }, + ); + + // 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: survivorOrderIds }, + { + createdClientIds: batch.clientIds, + cancelledOrderIds: [], + }, + { + receiptIdentity: { + accountIndex, + marketIndex: market.marketId, + }, + }, ); - if (rollback.outcome === 'timeout') { + await observeProtection(); + 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') { + if (partialIntent) { + throw new Error( + 'Partial replacement failed after cancellation; explicit recovery is required', + ); + } + // 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.#finishRecoverySuccessor( + journal, + 'failed', + await readActiveRaw(), + generationAtIntent, + ); + await this.#clearTpslJournal( + settlementKey, + journal.operationId, + await readActiveRaw(), + ); + 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). + childOrderIds = [ + ...(childOrderIds ?? []), + ...createVisibility.childOrderIds, + ]; + if (!createVisibility.executedCreated) { + createdIdsNeedingFinalCheck.push(...batch.clientIds); + } + if ( + independentPair && + batch !== batches.at(-1) && + (createVisibility.executedCreated || + receipt?.legs[0].status !== 'resting') + ) { + throw new Error( + 'Independent protection first leg executed; the stale sibling was not sent', + ); + } + 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 }, + ); + } } - 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`, + } + + if (!partialIntent) { + 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), + }, + { + ...(partialIntent?.version === 2 + ? { + createdGroups: createdIdsNeedingFinalCheck.map((id) => [ + id, + ]), + } + : {}), + receiptIdentity: { + accountIndex, + marketIndex: market.marketId, + }, + }, + ); + 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)); + if (sourceRecovery) { + await this.#finishRecoverySuccessor( + journal, + 'failed', + rawNow, + generationAtIntent, + ); + } else { + 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', + partialIntent: journal.partialIntent, + priorIntent: journal.intent, + priorTriggers: journal.priorTriggers, + survivingOrderIds, + operationId: journal.operationId, + recordedAt: Date.now(), + }); + } + await this.#clearTpslJournal( + settlementKey, + journal.operationId, + await readActiveRaw(), + ); + this.#assertSession(generationAtIntent); + throw new Error( + partialIntent + ? `Lighter partial replacement for ${params.symbol} failed after prior protection was removed; list the pending obligation and call resolveRecoveryProtection with its exact recovery ID and fresh current-position intent` + : `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)`, + ); + } + if ( + createdIdsNeedingFinalCheck.length > 0 && + (settled.childOrderIds.length !== + createdIdsNeedingFinalCheck.length || + settled.childOrderIds.some( + (id, index) => + id !== + childOrderIds?.[ + createdClientIds.indexOf( + createdIdsNeedingFinalCheck[index], + ) + ], + )) + ) { + throw new Error( + 'Lighter TP/SL receipt identity changed during settlement', + ); + } + await this.#verifyRetainedRecoveryCoverage( + journal, + readActiveRaw, + readInactiveFor, + ); + await this.#finishRecoverySuccessor( + journal, + 'settled', + await readActiveRaw(), + generationAtIntent, + ); + await this.#clearTpslJournal( + settlementKey, + journal.operationId, + await readActiveRaw(), + ); + // 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. + if (sourceRecovery) { + await this.#verifyRetainedRecoveryCoverage( + journal, + readActiveRaw, + readInactiveFor, + ); + await this.#finishRecoverySuccessor( + journal, + 'settled', + await readActiveRaw(), + generationAtIntent, + ); + } else { + await this.#clearTpslManualRecovery(settlementKey); + } + this.#assertSession(generationAtIntent); + } catch (error) { + try { + await observeProtection(); + } catch (observationError) { + this.#deps.debugLogger.log( + '[LighterProvider] Protection receipt observation unavailable', + { error: String(observationError) }, ); } - // 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. + try { + if (partialIntent && journal.attempts.length === 0) { + this.#assertSession(generationAtIntent); + await this.#finishRecoverySuccessor( + journal, + 'failed', + journal.sourceRecoveryOperationId + ? await readActiveRaw() + : [], + generationAtIntent, + ); + await this.#clearTpslJournal( + settlementKey, + journal.operationId, + ); + } + } catch (cleanupError) { this.#deps.debugLogger.log( - '[LighterProvider] replacement trigger executed immediately', - { symbol: params.symbol }, + '[LighterProvider] Proven-unsent protection cleanup remains pending', + { error: String(cleanupError) }, ); } + throw error; } - - 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( + try { + await this.#clearSettledPreviousSlotWarnings( + settlementKey, + accountIndex, + params.symbol, + generationAtIntent, 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)`, - ); + } catch (error) { + if (error instanceof LighterSessionCancelledError) { + throw error; } - 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); + this.#deps.debugLogger.log( + '[LighterProvider] Previous-slot warning cleanup remains pending', + { error: String(error), symbol: params.symbol }, + ); } - // 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, + successorSlot, ); - return { success: true }; + return { + success: true, + ...(childOrderIds === undefined ? {} : { childOrderIds }), + ...(positionProtection ? { positionProtection } : {}), + }; } catch (error) { const wrappedError = ensureError( error, @@ -6577,15 +16326,116 @@ export class LighterProvider implements PerpsProvider { ...this.#getErrorContext('updatePositionTPSL'), }, ); - return { success: false, error: wrappedError.message }; + return { + success: false, + error: wrappedError.message, + ...(positionProtection ? { positionProtection } : {}), + }; } - } + }; + + /** Validate isolated collateral against a fresh, uniquely identified position. + * @param symbol - Requested market symbol. + * @param market - Native market identity. + * @param amount - Signed collateral adjustment. + * @param generation - Original session generation. + * @param expected - Original position identity, when rechecking a queued intent. + * @returns The captured position identity. + */ + readonly #validateIsolatedMargin = async ( + symbol: string, + market: LighterOrderBookMeta, + amount: number, + generation: number, + expected?: Pick, + ): Promise< + Pick + > => { + if (market.status !== 'active' || market.marketType !== 'perp') { + throw new Error( + 'Lighter margin adjustment requires an active perpetual market', + ); + } + this.#assertSession(generation); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const response = await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generation); + const accounts = Array.isArray(response.accounts) ? response.accounts : []; + const account = accounts.length === 1 ? accounts[0] : undefined; + if ( + !account || + account.index !== accountIndex || + account.accountType !== 0 || + typeof account.l1Address !== 'string' || + account.l1Address.toLowerCase() !== this.#boundAddress || + !Array.isArray(account.positions) + ) { + throw new Error('Lighter margin account state is not authoritative'); + } + const rows = account.positions.filter( + (row) => row.marketId === market.marketId || row.symbol === symbol, + ); + const row = rows.length === 1 ? rows[0] : undefined; + if ( + !row || + row.marketId !== market.marketId || + row.symbol !== symbol || + row.marginMode !== LIGHTER_MARGIN_MODE_ISOLATED || + (row.sign !== 1 && row.sign !== -1) || + parseFinitePositive(row.position) === null || + parseFinitePositive(row.avgEntryPrice) === null + ) { + throw new Error( + 'Lighter margin adjustment requires an open isolated position', + ); + } + if ( + expected && + (row.sign !== expected.sign || + !new BigNumber(row.position).eq(expected.position) || + !new BigNumber(row.avgEntryPrice).eq(expected.avgEntryPrice)) + ) { + throw new Error( + 'Lighter margin position changed; refresh before adjusting collateral', + ); + } + const available = parseStrictDecimal(account.availableBalance); + const allocated = + row.allocatedMargin === undefined + ? null + : parseStrictDecimal(row.allocatedMargin); + if ( + amount > 0 && + (available === null || + available < 0 || + new BigNumber(amount).gt(account.availableBalance)) + ) { + throw new Error('Lighter margin addition exceeds available collateral'); + } + if ( + amount < 0 && + (allocated === null || + allocated < 0 || + new BigNumber(-amount).gt(row.allocatedMargin ?? '0')) + ) { + throw new Error( + 'Lighter margin removal exceeds authoritative allocated collateral', + ); + } + return { + position: row.position, + sign: row.sign, + avgEntryPrice: row.avgEntryPrice, + }; + }; - async updateMargin(params: UpdateMarginParams): Promise { + async updateMargin(input: UpdateMarginParams): Promise { + const params = { ...input }; try { this.#ensureSessionBinding(); const generationAtIntent = this.#sessionGeneration; - const markets = await this.#ensureMarkets(); + const markets = await this.#ensureMarkets(true); const market = markets.get(params.symbol); if (!market) { return { @@ -6606,9 +16456,22 @@ export class LighterProvider implements PerpsProvider { // 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); + if ( + !new BigNumber(params.amount).abs().shiftedBy(6).eq(marginAmountInt) + ) { + throw new Error( + 'Lighter margin adjustment requires exact micro-USDC precision', + ); + } // 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); + const position = await this.#validateIsolatedMargin( + params.symbol, + market, + amount, + generationAtIntent, + ); await this.#ensureSignerReady(); const accountIndex = await this.#ensureAccountIndex(); // USDC uses 6 decimals; direction 1 adds isolated margin, 0 removes it @@ -6616,6 +16479,13 @@ export class LighterProvider implements PerpsProvider { await this.#withVenueNonce( accountIndex, async (nonce, submit) => { + await this.#validateIsolatedMargin( + params.symbol, + market, + amount, + generationAtIntent, + position, + ); const signed = await this.#getSignerBridge().execute({ function: '_signUpdateMargin', params: [ @@ -6636,6 +16506,16 @@ export class LighterProvider implements PerpsProvider { { ...extractDispatchIdentity(signed), intent: `updateMargin:${params.symbol}:${params.amount}`, + requireExecution: true, + beforeDispatch: async () => { + await this.#validateIsolatedMargin( + params.symbol, + market, + amount, + generationAtIntent, + position, + ); + }, }, ); }, @@ -7014,7 +16894,28 @@ export class LighterProvider implements PerpsProvider { // whichever awaited venue read fails (markets, margin metadata, fresh // price, live positions, data integrity). try { - return await this.#validateOrderChecks(params); + this.#ensureSessionBinding(); + if ( + params.expectedScaleLadder !== undefined && + params.orderType !== 'scale' + ) { + return { + isValid: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE, + }; + } + const intent = { + ...params, + expectedScaleLadder: captureExpectedScaleLadder( + params.expectedScaleLadder, + ), + }; + if (intent.orderType === 'scale') { + this.#ensureSessionBinding(); + await this.#prepareScaleOrder(intent, this.#sessionGeneration); + return { isValid: true }; + } + return await this.#validateOrderChecks(intent); } catch (error) { return { isValid: false, @@ -7026,34 +16927,44 @@ export class LighterProvider implements PerpsProvider { readonly #validateOrderChecks = async ( params: OrderParams, ): Promise<{ isValid: boolean; error?: string }> => { - if (params.marginMode !== undefined) { - return { - isValid: false, - error: PERPS_ERROR_CODES.ORDER_MARGIN_MODE_UNSUPPORTED, - }; + if (params.orderType === 'chase' && this.#chaseTestnetProbe) { + this.#ensureSessionBinding(); + const generation = this.#sessionGeneration; + await this.#prepareChaseIntent(params, generation); + this.#assertSession(generation); + return { isValid: true }; } // Mirrors placeOrder's own rejections so validation never approves an // order shape the placement path would refuse. - if (params.orderType !== 'limit' && params.orderType !== 'market') { + if ( + params.orderType !== 'limit' && + params.orderType !== 'market' && + !isTriggerOrderType(params.orderType) + ) { 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', - }; + const triggerIntentError = getLighterTriggerIntentError(params); + if (triggerIntentError) { + return { isValid: false, error: triggerIntentError }; } - if (params.timeInForce === 'ALO') { - return { - isValid: false, - error: 'Lighter placement does not support post-only (ALO) yet', - }; + const attachedIntentError = getLighterAttachedIntentError(params); + if (attachedIntentError) { + return { isValid: false, error: attachedIntentError }; } - if (params.orderType === 'limit' && !params.price) { + const hasAttached = + params.takeProfitPrice !== undefined || + params.stopLossPrice !== undefined; + const postOnlyError = getLighterPostOnlyIntentError(params); + if (postOnlyError) { + return { isValid: false, error: postOnlyError }; + } + if (isLimitExecutionOrderType(params.orderType) && !params.price) { return { isValid: false, error: 'Limit order requires a price' }; } - if (params.orderType === 'limit' && params.price !== undefined) { + if ( + isLimitExecutionOrderType(params.orderType) && + 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 @@ -7085,7 +16996,12 @@ export class LighterProvider implements PerpsProvider { if (!hasUsdSizing && parseFinitePositive(params.size) === null) { return { isValid: false, error: 'Order size must be positive' }; } - const markets = await this.#ensureMarkets(); + const markets = await this.#ensureMarkets( + isTriggerOrderType(params.orderType) || + hasAttached || + params.timeInForce === 'ALO' || + params.marginMode !== undefined, + ); const market = markets.get(params.symbol); if (!market) { return { @@ -7093,6 +17009,29 @@ export class LighterProvider implements PerpsProvider { error: `Unknown Lighter market: ${params.symbol}`, }; } + if ( + params.timeInForce === 'ALO' && + (market.status !== 'active' || market.marketType !== 'perp') + ) { + return { + isValid: false, + error: 'Lighter post-only requires an active perpetual market', + }; + } + if ( + (isTriggerOrderType(params.orderType) || hasAttached) && + (market.status !== 'active' || market.marketType !== 'perp') + ) { + return { + isValid: false, + error: 'Lighter trigger orders require an active perpetual market', + }; + } + await this.#validateExplicitMarginMode( + params, + market, + this.#sessionGeneration, + ); if (params.leverage !== undefined) { // Same authoritative-metadata requirement as placement. const maxLeverage = await this.#requireMarketMaxLeverage(params.symbol); @@ -7118,10 +17057,14 @@ export class LighterProvider implements PerpsProvider { // the wire-range check below inspects the exact signed value. let referencePrice: number; let executionPrice: number; - if (params.orderType === 'market') { + if (isTriggerOrderType(params.orderType)) { + const resolved = resolveLighterTriggerPrices(params, market); + referencePrice = resolved.referencePrice; + executionPrice = resolved.executionPrice; + } else if (params.orderType === 'market') { const slippageFraction = params.maxSlippageBps === undefined - ? (params.slippage ?? 0.05) + ? (params.slippage ?? LIGHTER_DEFAULT_SLIPPAGE_BPS / 10_000) : params.maxSlippageBps / 10_000; // A validator must RESOLVE to an invalid result, never reject: the // fresh-price lookup can throw on REST failure. @@ -7155,6 +17098,7 @@ export class LighterProvider implements PerpsProvider { ); executionPrice = referencePrice; } + resolveLighterAttachedChildren(params, market, referencePrice); if (referencePrice > 0) { // USD-derived sizes snap onto the venue grid (placement parity); // explicit size strings stay verbatim. @@ -7199,7 +17143,7 @@ export class LighterProvider implements PerpsProvider { // can THROW (capability gates, venue-data integrity): a validator // must resolve to an explicit invalid result, never reject. let verifiedFullClose = false; - if (params.reduceOnly) { + if (params.reduceOnly && !isTriggerOrderType(params.orderType)) { try { verifiedFullClose = await this.#isVerifiedFullClose( params.symbol, @@ -7235,6 +17179,19 @@ export class LighterProvider implements PerpsProvider { }; } } + if (params.timeInForce === 'ALO') { + await this.#assertPostOnlyBook( + market.marketId, + params.isBuy, + fromLighterInteger( + toSignerWirePriceInteger( + executionPrice, + market.supportedPriceDecimals, + ), + market.supportedPriceDecimals, + ), + ); + } return { isValid: true }; }; @@ -7315,7 +17272,7 @@ export class LighterProvider implements PerpsProvider { } else { const slippageFraction = params.maxSlippageBps === undefined - ? 0.05 + ? LIGHTER_DEFAULT_SLIPPAGE_BPS / 10_000 : params.maxSlippageBps / 10_000; // Same validator contract as validateOrder: REST failures resolve. let resolved: @@ -7477,6 +17434,115 @@ export class LighterProvider implements PerpsProvider { ); } + /** + * Validate an explicit collateral mode from authoritative account rows. + * No signer setup or default-mode fallback is permitted for this request. + * @param params - Requested order intent; explicit mode requires leverage. + * @param market - Refreshed public market metadata. + * @param generation - Session captured before the reads. + * @param requireSelectedMode - Require the executed selection to be visible before exposure. + * @returns Wire mode, or null for the unchanged omitted-mode behavior. + */ + readonly #validateExplicitMarginMode = async ( + params: OrderParams, + market: LighterOrderBookMeta, + generation: number, + requireSelectedMode = false, + ): Promise => { + if (params.marginMode === undefined) { + return null; + } + if (params.marginMode !== 'cross' && params.marginMode !== 'isolated') { + throw new Error(PERPS_ERROR_CODES.ORDER_MARGIN_MODE_INVALID); + } + if (!Number.isInteger(params.leverage) || (params.leverage ?? 0) < 1) { + throw new Error(PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID); + } + if (market.status !== 'active' || market.marketType !== 'perp') { + throw new Error(PERPS_ERROR_CODES.ORDER_MARGIN_MODE_UNSUPPORTED); + } + this.#assertSession(generation); + const accountIndex = await this.#ensureAccountIndex(); + this.#assertSession(generation); + const response = await this.#clientService.getAccountByIndex(accountIndex); + this.#assertSession(generation); + const accounts = Array.isArray(response.accounts) ? response.accounts : []; + const account = accounts.length === 1 ? accounts[0] : undefined; + if ( + !account || + account.index !== accountIndex || + account.accountType !== 0 || + typeof account.l1Address !== 'string' || + account.l1Address.toLowerCase() !== this.#boundAddress || + !Array.isArray(account.positions) + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + const rows = account.positions.filter( + (row) => row.marketId === market.marketId || row.symbol === params.symbol, + ); + const row = rows.length === 1 ? rows[0] : undefined; + if ( + rows.length > 1 || + (row && + (row.marketId !== market.marketId || row.symbol !== params.symbol)) + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + // Counts are the account endpoint's authoritative resting/pending order + // inventory. Without a target row, any order makes its mode unprovable. + if ( + !Number.isSafeInteger(account.totalOrderCount) || + account.totalOrderCount < 0 || + !Number.isSafeInteger(account.pendingOrderCount) || + account.pendingOrderCount < 0 || + (!row && (account.totalOrderCount > 0 || account.pendingOrderCount > 0)) + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + const requested = + params.marginMode === 'cross' + ? LIGHTER_MARGIN_MODE_CROSS + : LIGHTER_MARGIN_MODE_ISOLATED; + if (row) { + const size = parseStrictDecimal(row.position); + const mode = row.marginMode ?? LIGHTER_MARGIN_MODE_CROSS; + const pending = row.pendingOrderCount ?? account.pendingOrderCount; + const tied = row.positionTiedOrderCount ?? account.totalOrderCount; + if ( + size === null || + size < 0 || + (size > 0 && row.sign !== 1 && row.sign !== -1) || + !Number.isSafeInteger(pending) || + pending < 0 || + !Number.isSafeInteger(tied) || + tied < 0 || + !Number.isSafeInteger(row.openOrderCount) || + row.openOrderCount < 0 || + (mode !== LIGHTER_MARGIN_MODE_CROSS && + mode !== LIGHTER_MARGIN_MODE_ISOLATED) + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + if ( + mode !== requested && + (size !== 0 || row.openOrderCount > 0 || pending > 0 || tied > 0) + ) { + throw new Error( + size === 0 + ? PERPS_ERROR_CODES.ORDER_MARGIN_MODE_ORDER_OPEN + : PERPS_ERROR_CODES.ORDER_MARGIN_MODE_POSITION_OPEN, + ); + } + } + if (requireSelectedMode && (!row || row.marginMode !== requested)) { + throw new Error( + 'Lighter selected margin mode is not visible; refresh before placing exposure', + ); + } + return requested; + }; + /** * Margin mode the venue will accept for a leverage update on this * market: an existing position's current mode (the venue refuses mode @@ -7652,6 +17718,7 @@ export class LighterProvider implements PerpsProvider { fresh.set(detail.symbol, { marketId: detail.marketId, minInitial: detail.minInitialMarginFraction, + defaultInitial: detail.defaultInitialMarginFraction, maintenance: detail.maintenanceMarginFraction, lastTradePrice: detail.lastTradePrice, }); @@ -7780,7 +17847,7 @@ export class LighterProvider implements PerpsProvider { return NOOP_UNSUBSCRIBE; } this.#orderSubscribers.add(params); - if (this.#wsOrders.size > 0) { + if (this.#hasOrdersSnapshot || this.#wsOrders.size > 0) { params.callback([...this.#wsOrders.values()]); } this.#ensureAccountChannels(); @@ -7796,6 +17863,22 @@ export class LighterProvider implements PerpsProvider { } this.#fillSubscribers.add(params); this.#ensureAccountChannels(); + const generation = this.#sessionGeneration; + const channelsPromise = this.#accountChannelsPromise; + // A late subscriber may join after the venue's one initial trade snapshot. + // Replay only after authentication succeeds for the same channel session. + channelsPromise + ?.then(() => { + this.#assertSession(generation); + if ( + this.#accountChannelsPromise === channelsPromise && + this.#fillSubscribers.has(params) && + this.#wsFills !== null + ) { + params.callback([...this.#wsFills], true); + } + }) + .catch(() => undefined); return () => { this.#fillSubscribers.delete(params); this.#releaseChannelIfUnused(); @@ -7808,6 +17891,26 @@ export class LighterProvider implements PerpsProvider { // prices when no WebSocket implementation is available. // ============================================================================ + /** + * Notify each order/fill subscriber without allowing one listener to stop others. + * + * @param error - Account channel setup failure. + */ + readonly #notifySubscriptionError = (error: Error): void => { + for (const [label, subscribers] of [ + ['orders', this.#orderSubscribers], + ['fills', this.#fillSubscribers], + ] as const) { + for (const subscriber of subscribers) { + try { + subscriber.onError?.(error); + } catch (subscriberError) { + this.#logSubscriberError(label, subscriberError); + } + } + } + }; + /** * Resolve the Lighter account index and request the account-scoped * channels. When the venue definitively reports no Lighter account, @@ -7823,7 +17926,9 @@ export class LighterProvider implements PerpsProvider { } const generation = this.#sessionGeneration; let channelsRequested = false; - const setupPromise = (async (): Promise => { + // Auth setup crosses awaits before consulting this promise identity. + let setupPromise: Promise | undefined = undefined; + setupPromise = (async (): Promise => { try { // Warm the margin cache before any WS position frame is adapted. await this.#ensureMarketMargins().catch(() => undefined); @@ -7839,6 +17944,7 @@ export class LighterProvider implements PerpsProvider { this.#requestChannel(`account_all_positions/${accountIndex}`); this.#requestChannel(`account_all_trades/${accountIndex}`); channelsRequested = true; + const channelAddress = this.#boundAddress; try { const auth = await this.#getAuthToken(); this.#assertSession(generation); @@ -7848,6 +17954,17 @@ export class LighterProvider implements PerpsProvider { '[LighterProvider] orders channel skipped (no auth token)', { error: String(error) }, ); + this.#ensureSessionBinding(); + // Signer failure retires the write generation, but this channel + // still owns its wallet's error. A replaced channel or wallet does not. + if ( + !this.#isDisconnected && + channelAddress !== null && + channelAddress === this.#boundAddress && + this.#accountChannelsPromise === setupPromise + ) { + this.#notifySubscriptionError(ensureError(error)); + } // Setup failures are not authoritative empty order state. Account // switches and deselection already emit their synchronous reset. channelsRequested = false; @@ -7868,6 +17985,13 @@ export class LighterProvider implements PerpsProvider { generation === this.#sessionGeneration ) { this.#emitAccountBindingReset(); + } else if ( + !this.#isDisconnected && + this.#boundAddress !== null && + generation === this.#sessionGeneration && + this.#accountChannelsPromise === setupPromise + ) { + this.#notifySubscriptionError(ensureError(error)); } // An aborted previous-account setup has no authority over the new // session. Current-session failures also preserve the last known data. @@ -7888,7 +18012,6 @@ export class LighterProvider implements PerpsProvider { ) { this.#accountChannelsPromise = null; } - return; }) .catch(() => undefined); this.#ensureStream(); @@ -7998,7 +18121,6 @@ export class LighterProvider implements PerpsProvider { } this.#wsWantedChannels.set(channel, { auth: freshToken }); this.#sendSubscribe(channel, freshToken); - return; }) .catch((error) => { this.#deps.debugLogger.log( @@ -8152,32 +18274,62 @@ export class LighterProvider implements PerpsProvider { this.#handleTradesMessage(message); return; } - if (type.includes('account_all_orders') && message.orders) { - const isSnapshot = type.startsWith('subscribed'); - const nextOrders = isSnapshot - ? new Map() - : new Map(this.#wsOrders); - 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), + if (type.includes('account_all_orders')) { + if (message.orders === undefined) { + return; + } + try { + if ( + message.orders === null || + typeof message.orders !== 'object' || + Array.isArray(message.orders) || + !Object.values(message.orders).every( + (rows) => + Array.isArray(rows) && + rows.every( + (row) => + row !== null && + typeof row === 'object' && + !Array.isArray(row), + ), + ) + ) { + throw new Error( + 'Invalid Lighter venue data: malformed orders container', ); - const isOpen = - adapted.status === 'queued' || adapted.status === 'open'; - if (isOpen) { - nextOrders.set(adapted.orderId, adapted); - } else { - nextOrders.delete(adapted.orderId); + } + const isSnapshot = type.startsWith('subscribed'); + const nextOrders = isSnapshot + ? new Map() + : new Map(this.#wsOrders); + 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) { + nextOrders.set(adapted.orderId, adapted); + } else { + nextOrders.delete(adapted.orderId); + } } } + if (isSnapshot) { + this.#hasOrdersSnapshot = true; + } + this.#wsOrders.clear(); + for (const [orderId, order] of nextOrders) { + this.#wsOrders.set(orderId, order); + } + this.#emitToOrderSubscribers([...this.#wsOrders.values()]); + } catch (error) { + this.#hasOrdersSnapshot = false; + throw error; } - this.#wsOrders.clear(); - for (const [orderId, order] of nextOrders) { - this.#wsOrders.set(orderId, order); - } - this.#emitToOrderSubscribers([...this.#wsOrders.values()]); } }; @@ -8357,13 +18509,29 @@ export class LighterProvider implements PerpsProvider { * @param message - Camelized account_all_trades payload. */ readonly #handleTradesMessage = (message: LighterWsTradesMessage): void => { - if (this.#fillSubscribers.size === 0) { + if (message.trades === undefined) { return; } const isSnapshot = (message.type ?? '').startsWith('subscribed'); + if ( + message.trades === null || + typeof message.trades !== 'object' || + Array.isArray(message.trades) || + !Object.values(message.trades).every( + (rows) => + Array.isArray(rows) && + rows.every( + (row) => + row !== null && typeof row === 'object' && !Array.isArray(row), + ), + ) + ) { + this.#wsFills = null; + throw new Error('Invalid Lighter venue data: malformed trades container'); + } const fills: OrderFill[] = []; let droppedUnsupportedFill = false; - for (const marketTrades of Object.values(message.trades ?? {})) { + for (const marketTrades of Object.values(message.trades)) { for (const trade of marketTrades) { const symbol = this.#marketsById.get(trade.marketId)?.symbol ?? @@ -8385,6 +18553,11 @@ export class LighterProvider implements PerpsProvider { } } } + // A cache is replayable only after a complete validated snapshot. + // Missing even one fill invalidates that authority until a new snapshot. + if (droppedUnsupportedFill) { + this.#wsFills = null; + } if (fills.length === 0 && !isSnapshot) { return; } @@ -8398,6 +18571,24 @@ export class LighterProvider implements PerpsProvider { ); return; } + if (isSnapshot || this.#wsFills !== null) { + const history = isSnapshot ? fills : [...fills, ...(this.#wsFills ?? [])]; + const seen = new Set(); + this.#wsFills = history + .filter((fill) => { + const id = fill.fillId; + if (id === undefined) { + throw new Error('Lighter fill is missing its venue trade identity'); + } + if (seen.has(id)) { + return false; + } + seen.add(id); + return true; + }) + .sort((first, second) => second.timestamp - first.timestamp) + .slice(0, LIGHTER_FILL_REPLAY_LIMIT); + } for (const subscriber of this.#fillSubscribers) { try { subscriber.callback(fills, isSnapshot); @@ -8541,6 +18732,8 @@ export class LighterProvider implements PerpsProvider { this.#wsWantedChannels.clear(); this.#accountChannelsPromise = null; this.#wsPositions.clear(); + this.#hasOrdersSnapshot = false; + this.#wsFills = null; this.#wsOrders.clear(); this.#orderBookState.clear(); this.#candleSeries.clear(); @@ -8597,7 +18790,6 @@ export class LighterProvider implements PerpsProvider { params.callback(seeded); this.#requestChannel(`candle/${market.marketId}/${resolution}`); this.#ensureStream(); - return; }) .catch((error: unknown) => { this.#deps.debugLogger.log('[LighterProvider] candle seed failed', { @@ -8683,7 +18875,6 @@ export class LighterProvider implements PerpsProvider { subscribers.add(params); this.#requestChannel(`order_book/${marketId}`); this.#ensureStream(); - return; }) .catch((error: unknown) => { if (!this.#isDisconnected && !released) { diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index b1cb486b6d8..302431079cb 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -31,6 +31,7 @@ import type { import type { CandleData } from '../types/perps-types.js'; import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js'; import { ensureError, isAbortError } from '../utils/errorUtils.js'; +import { createGuardedHyperLiquidClient } from '../utils/guardedHyperLiquidClient.js'; import { getPerpsConnectionAttemptContext } from '../utils/perpsConnectionAttemptContext.js'; /** @@ -509,14 +510,30 @@ export class HyperLiquidClientService { /** * Get the exchange client * - * @returns The initialized ExchangeClient instance. + * @param beforeDispatch - Optional operation-local fence after SDK signing. + * @returns The initialized client, or an isolated guarded client. */ - public getExchangeClient(): ExchangeClient { + public getExchangeClient( + beforeDispatch?: () => Promise, + ): ExchangeClient { if (!this.#exchangeClient) { this.ensureInitialized(); throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); } - return this.#exchangeClient; + if (!beforeDispatch) { + return this.#exchangeClient; + } + const wallet = this.#walletParams; + const onExchangeRequest = this.#onExchangeRequest; + return createGuardedHyperLiquidClient( + this.#exchangeClient, + beforeDispatch, + (payload) => { + if (wallet) { + onExchangeRequest?.(payload, undefined, wallet); + } + }, + ); } /** diff --git a/packages/perps-controller/src/services/LighterChaseService.ts b/packages/perps-controller/src/services/LighterChaseService.ts new file mode 100644 index 00000000000..280e76c6974 --- /dev/null +++ b/packages/perps-controller/src/services/LighterChaseService.ts @@ -0,0 +1,1484 @@ +import { + assert, + type, + array, + string, + number, + boolean, + enums, + optional, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; + +import { BASIS_POINTS_DIVISOR } from '../constants/hyperLiquidConfig.js'; +import { + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_MARKET_ID, + LIGHTER_MAX_DECIMALS, + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_MAX_ORDER_PRICE, + LIGHTER_MAX_CLIENT_ORDER_INDEX, + LIGHTER_NATIVE_PROBE_RECORD_LIMIT, + LIGHTER_NATIVE_PROBE_CANCEL_LIMIT, + LIGHTER_NATIVE_PROBE_MAX_NOTIONAL, + LIGHTER_CHASE_MIN_INTERVAL_MS, + LIGHTER_CHASE_MAX_DURATION_MS, + LIGHTER_CHASE_MAX_REPRICINGS, + LIGHTER_CHASE_MAX_DISTANCE_BPS, + LIGHTER_CHASE_QUOTE_MAX_AGE_MS, + LIGHTER_CHASE_HANDLE_MAX_LENGTH, + LIGHTER_MAX_TRADING_API_KEY_INDEX, + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS, + LIGHTER_CHASE_CANCEL_SETTLEMENT_POLL_MS, +} from '../constants/lighterConfig.js'; +import type { + ChaseOrder, + ChaseOrderStatus, + PerpsPlatformDependencies, + ReconcileChaseOrderCancellationParams, +} from '../types/index.js'; +import type { LighterChaseChildObservation } from '../utils/lighterChase.js'; +import { + isLighterTxHash, + isLighterTxExpiry, +} from './lighterDispatchIdentity.js'; + +const OwnerStruct = type({ + wallet: string(), + network: enums(['testnet', 'mainnet']), + accountIndex: number(), + apiKeyIndex: number(), +}); +const IntentStruct = type({ + owner: OwnerStruct, + handle: string(), + symbol: string(), + marketId: number(), + isBuy: boolean(), + reduceOnly: boolean(), + originalSize: string(), + arrivalPrice: string(), + sizeDecimals: number(), + priceDecimals: number(), + startedAt: number(), + intervalMs: number(), + maxDurationMs: number(), + maxRepricings: number(), + maxDistanceBps: number(), + maxNotional: string(), + minBaseAmount: string(), + minQuoteAmount: string(), +}); +const DispatchStruct = type({ + phase: enums(['prepared', 'signed', 'attempted', 'acknowledged', 'failed']), + nonce: optional(number()), + txHash: optional(string()), + expiresAt: optional(number()), + acknowledged: optional(boolean()), +}); +const ObservationStruct = type({ + orderId: string(), + terminal: boolean(), + filledSize: string(), + filledNotional: string(), + remainingSize: string(), +}); +const ChildStruct = type({ + clientOrderId: string(), + size: string(), + price: string(), + quotedAt: number(), + placement: DispatchStruct, + cancellations: array(DispatchStruct), + observation: optional(ObservationStruct), +}); +const StatusStruct = enums([ + 'active', + 'termination_pending', + 'backgrounded', + 'max_distance_reached', + 'duration_reached', + 'repricing_limit_reached', + 'filled', + 'canceled', + 'failed', +]); +const RecordStruct = type({ + intent: IntentStruct, + children: array(ChildStruct), + status: StatusStruct, + stopReason: optional(StatusStruct), + repricings: number(), + lastTickAt: number(), + executedSize: string(), + executedNotional: string(), + error: optional(string()), +}); +const JournalStruct = type({ + version: enums([1]), + records: array(RecordStruct), +}); + +export type LighterChaseOwner = Infer; +export type LighterChaseIntent = Infer; +export type LighterChaseDispatch = Infer; +export type LighterChaseChild = Infer; +export type LighterChaseRecord = Infer; +type Journal = Infer; +export type LighterChaseDispatchHooks = { + prepareQuote: (price: string) => Promise; + signed: (identity: { + nonce: number; + txHash: string; + expiresAt: number; + }) => Promise; + beforeDispatch: () => Promise; + notDispatched: () => Promise; + accepted: () => void; + afterAccepted: () => Promise; +}; +export type LighterChaseIo = { + assertCurrent: () => void; + now: () => number; + allocateClientId: () => string; + quote: () => Promise; + place: ( + child: LighterChaseChild, + hooks: LighterChaseDispatchHooks, + ) => Promise; + cancel: ( + child: LighterChaseChild, + hooks: LighterChaseDispatchHooks, + ) => Promise; + observe: ( + child: LighterChaseChild, + recordEvidence: ( + dispatch: LighterChaseDispatch, + acknowledged: boolean | undefined, + ) => Promise, + ) => Promise; +}; +/** Accepted dispatches may precede their exact order snapshot. */ +export class LighterChaseObservationPendingError extends Error {} + +const queues = new Map>(); + +/** + * @param owner - Durable account scope; each record separately binds its original key. + * @returns Collision-safe storage/lock key. + */ +function ownerKey(owner: LighterChaseOwner): string { + return `lighterChase:${JSON.stringify([owner.network, owner.wallet, owner.accountIndex])}`; +} + +/** + * @param value - Exact decimal string. + * @returns Finite nonnegative decimal, never a coerced zero. + */ +function amount(value: string): BigNumber { + if (!/^\d+(?:\.\d+)?$/u.test(value)) { + throw new Error('Invalid Lighter Chase amount'); + } + const result = new BigNumber(value); + if (!result.isFinite() || result.isNegative()) { + throw new Error('Invalid Lighter Chase amount'); + } + return result; +} + +/** + * @param value - Native exact value. + * @param decimals - Native fixed precision. + * @param maximum - Native integer upper bound. + */ +function validateWire(value: string, decimals: number, maximum: string): void { + const units = amount(value).shiftedBy(decimals); + if ( + !Number.isSafeInteger(decimals) || + decimals < 0 || + decimals > LIGHTER_MAX_DECIMALS || + !units.isInteger() || + units.lt(1) || + units.gt(maximum) + ) { + throw new Error('Invalid Lighter Chase wire amount'); + } +} + +/** + * @param intent - Immutable new or persisted intent. + */ +function validateIntent(intent: LighterChaseIntent): void { + assert(intent, IntentStruct); + const { owner } = intent; + if ( + !owner.wallet || + owner.wallet !== owner.wallet.toLowerCase() || + !Number.isSafeInteger(owner.accountIndex) || + owner.accountIndex < 0 || + !Number.isSafeInteger(owner.apiKeyIndex) || + owner.apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + owner.apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !intent.handle || + intent.handle.length > LIGHTER_CHASE_HANDLE_MAX_LENGTH || + !intent.symbol || + !Number.isSafeInteger(intent.marketId) || + intent.marketId < 0 || + intent.marketId > LIGHTER_MAX_MARKET_ID || + !Number.isSafeInteger(intent.startedAt) || + intent.startedAt <= 0 || + !Number.isSafeInteger(intent.intervalMs) || + intent.intervalMs < LIGHTER_CHASE_MIN_INTERVAL_MS || + !Number.isSafeInteger(intent.maxDurationMs) || + intent.maxDurationMs < intent.intervalMs || + intent.maxDurationMs > LIGHTER_CHASE_MAX_DURATION_MS || + !Number.isSafeInteger(intent.maxRepricings) || + intent.maxRepricings < 0 || + intent.maxRepricings > LIGHTER_CHASE_MAX_REPRICINGS || + !Number.isFinite(intent.maxDistanceBps) || + intent.maxDistanceBps <= 0 || + intent.maxDistanceBps >= LIGHTER_CHASE_MAX_DISTANCE_BPS || + amount(intent.maxNotional).lte(0) || + amount(intent.maxNotional).gt(LIGHTER_NATIVE_PROBE_MAX_NOTIONAL) + ) { + throw new Error('Invalid bounded Lighter Chase ownership or intent'); + } + validateWire( + intent.originalSize, + intent.sizeDecimals, + LIGHTER_MAX_BASE_AMOUNT, + ); + validateWire( + intent.arrivalPrice, + intent.priceDecimals, + LIGHTER_MAX_ORDER_PRICE, + ); + if ( + amount(intent.minBaseAmount).lte(0) || + amount(intent.minQuoteAmount).lte(0) + ) { + throw new Error('Invalid Lighter Chase venue minimum'); + } +} + +/** Dedicated bounded Chase ownership and serial cancel/replace lifecycle. */ +export class LighterChaseService { + readonly #storage: Pick< + PerpsPlatformDependencies['diskCache'], + 'getItem' | 'setItem' + >; + readonly #active = new Map(); + #epoch = 0; + readonly #stopRequests = new Map(); + + constructor(options: { + storage: Pick< + PerpsPlatformDependencies['diskCache'], + 'getItem' | 'setItem' + >; + }) { + this.#storage = options.storage; + } + + /** + * Stop all financial continuation synchronously, including in-flight starts. + * + * @param reason - Optional explicit suspension cause for active handles. + */ + interrupt(reason?: ChaseOrderStatus): void { + if (reason) { + for (const handle of this.#active.keys()) { + if (!this.#stopRequests.has(handle)) { + this.#stopRequests.set(handle, reason); + } + } + } + this.#epoch += 1; + this.#active.clear(); + } + + /** + * Disable this handle's continuation before queued discovery or cleanup. + * + * @param handle - Requested exact handle; ownership is validated by stop. + * @param symbol - Optional requested symbol, checked against active metadata before interruption. + */ + interruptHandle(handle: string, symbol?: string): void { + const active = this.#active.get(handle); + if (active && symbol !== undefined && active.symbol !== symbol) { + throw new Error('Lighter Chase active handle symbol mismatch'); + } + if (this.#active.has(handle) && !this.#stopRequests.has(handle)) { + this.#stopRequests.set(handle, 'canceled'); + } + this.#active.delete(handle); + } + + async #locked( + owner: LighterChaseOwner, + action: () => Promise, + ): Promise { + const key = ownerKey(owner); + const previous = queues.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then(action); + queues.set(key, pending); + try { + return await pending; + } finally { + if (queues.get(key) === pending) { + queues.delete(key); + } + } + } + + async #read( + owner: LighterChaseOwner, + io: Pick, + ): Promise { + io.assertCurrent(); + const journal = await this.#readCaptured(owner); + io.assertCurrent(); + return journal; + } + + async #readCaptured(owner: LighterChaseOwner): Promise { + const raw = await this.#storage.getItem(ownerKey(owner)); + if (raw === null) { + return { version: 1, records: [] }; + } + const parsed: unknown = JSON.parse(raw); + assert(parsed, JournalStruct); + if (parsed.records.length > LIGHTER_NATIVE_PROBE_RECORD_LIMIT) { + throw new Error('Lighter Chase journal capacity exceeded'); + } + const handles = new Set(); + const ids = new Set(); + for (const record of parsed.records) { + validateIntent(record.intent); + if ( + ownerKey(owner) !== ownerKey(record.intent.owner) || + handles.has(record.intent.handle) || + record.children.length > record.intent.maxRepricings + 1 || + !Number.isSafeInteger(record.repricings) || + record.repricings !== Math.max(0, record.children.length - 1) || + !Number.isSafeInteger(record.lastTickAt) || + record.lastTickAt < record.intent.startedAt + ) { + throw new Error('Lighter Chase persisted ownership is invalid'); + } + handles.add(record.intent.handle); + for (const child of record.children) { + if ( + !/^[1-9]\d*$/u.test(child.clientOrderId) || + BigInt(child.clientOrderId) > + BigInt(LIGHTER_MAX_CLIENT_ORDER_INDEX) || + ids.has(child.clientOrderId) || + child.cancellations.length > LIGHTER_NATIVE_PROBE_CANCEL_LIMIT || + !Number.isSafeInteger(child.quotedAt) || + child.quotedAt < record.intent.startedAt + ) { + throw new Error('Lighter Chase persisted child identity is invalid'); + } + ids.add(child.clientOrderId); + validateWire( + child.size, + record.intent.sizeDecimals, + LIGHTER_MAX_BASE_AMOUNT, + ); + validateWire( + child.price, + record.intent.priceDecimals, + LIGHTER_MAX_ORDER_PRICE, + ); + for (const dispatch of [child.placement, ...child.cancellations]) { + if ( + (dispatch.phase === 'acknowledged' && + dispatch.acknowledged === false) || + (dispatch.acknowledged === true && + dispatch.phase !== 'acknowledged' && + dispatch.phase !== 'failed') + ) { + throw new Error('Lighter Chase persisted acceptance is invalid'); + } + if (dispatch.phase !== 'prepared' && dispatch.phase !== 'failed') { + if ( + !Number.isSafeInteger(dispatch.nonce) || + (dispatch.nonce ?? -1) < 0 || + !isLighterTxHash(dispatch.txHash) || + !isLighterTxExpiry(dispatch.expiresAt) + ) { + throw new Error('Lighter Chase persisted dispatch is invalid'); + } + } + } + } + this.#totals(record); + if ( + record.status !== 'active' && + record.status !== 'termination_pending' && + !this.#settled(record) + ) { + throw new Error('Lighter Chase terminal journal lacks child evidence'); + } + } + return parsed; + } + + async #write( + owner: LighterChaseOwner, + journal: Journal, + io: Pick, + ): Promise { + io.assertCurrent(); + await this.#storage.setItem(ownerKey(owner), JSON.stringify(journal)); + io.assertCurrent(); + } + + async #recordEvidence( + record: LighterChaseRecord, + dispatch: LighterChaseDispatch, + acknowledged: boolean | undefined, + ): Promise { + // The owner operation lock remains held through transport and observation. + // Completion may outlive its session, but can update only this saved attempt. + const journal = await this.#readCaptured(record.intent.owner); + const savedRecord = journal.records.find( + (entry) => entry.intent.handle === record.intent.handle, + ); + const child = record.children.find( + (entry) => + entry.placement === dispatch || entry.cancellations.includes(dispatch), + ); + const savedChild = savedRecord?.children.find( + (entry) => entry.clientOrderId === child?.clientOrderId, + ); + const saved = + child?.placement === dispatch + ? savedChild?.placement + : savedChild?.cancellations[ + child?.cancellations.indexOf(dispatch) ?? -1 + ]; + if ( + !savedRecord || + !child || + !savedChild || + !saved || + JSON.stringify(savedRecord.intent) !== JSON.stringify(record.intent) || + savedChild.size !== child.size || + savedChild.price !== child.price || + savedChild.quotedAt !== child.quotedAt || + saved.nonce !== dispatch.nonce || + saved.txHash !== dispatch.txHash || + saved.expiresAt !== dispatch.expiresAt || + !['attempted', 'acknowledged'].includes(saved.phase) + ) { + throw new Error('Lighter Chase completion attempt mismatch'); + } + if (saved.phase === 'acknowledged' || saved.acknowledged === true) { + dispatch.acknowledged = true; + if (dispatch.phase !== 'failed') { + dispatch.phase = 'acknowledged'; + } + return; + } + if (acknowledged === undefined) { + delete saved.acknowledged; + delete dispatch.acknowledged; + } else { + saved.acknowledged = acknowledged; + dispatch.acknowledged = acknowledged; + if (acknowledged && saved.phase !== 'failed') { + saved.phase = 'acknowledged'; + dispatch.phase = 'acknowledged'; + } + } + await this.#storage.setItem( + ownerKey(record.intent.owner), + JSON.stringify(journal), + ); + } + + #running(record: LighterChaseRecord): void { + if ( + record.status !== 'active' || + this.#active.get(record.intent.handle)?.epoch !== this.#epoch + ) { + throw new Error( + 'Lighter Chase session was interrupted; never resume automatically', + ); + } + } + + #settled(record: LighterChaseRecord): boolean { + return record.children.every( + (child) => + child.placement.phase === 'failed' || + child.observation?.terminal === true, + ); + } + + #totals(record: LighterChaseRecord): void { + let filled = new BigNumber(0); + let notional = new BigNumber(0); + const orderIds = new Set(); + for (const child of record.children) { + if (!child.observation) { + continue; + } + const observed = child.observation; + if ( + !/^[1-9]\d*$/u.test(observed.orderId) || + orderIds.has(observed.orderId) || + child.placement.phase === 'failed' || + !amount(observed.filledSize) + .plus(amount(observed.remainingSize)) + .eq(child.size) + ) { + throw new Error('Lighter Chase observed child is invalid'); + } + orderIds.add(observed.orderId); + filled = filled.plus(amount(observed.filledSize)); + notional = notional.plus(amount(observed.filledNotional)); + } + if ( + filled.gt(record.intent.originalSize) || + notional.gt(record.intent.maxNotional) + ) { + throw new Error( + 'Lighter Chase cumulative execution exceeds immutable budget', + ); + } + record.executedSize = filled.toFixed(); + record.executedNotional = notional.toFixed(); + } + + async #observe( + record: LighterChaseRecord, + child: LighterChaseChild, + io: Pick, + ): Promise { + const observed = await io.observe(child, async (dispatch, acknowledged) => + this.#recordEvidence(record, dispatch, acknowledged), + ); + io.assertCurrent(); + if (!observed) { + if (child.placement.phase !== 'failed' || child.observation) { + throw new Error( + 'Lighter Chase absent observation lacks definitive placement failure', + ); + } + return; + } + const previous = child.observation; + if ( + previous && + (previous.orderId !== observed.orderId || + amount(observed.filledSize).lt(previous.filledSize) || + amount(observed.filledNotional).lt(previous.filledNotional) || + (previous.terminal && !observed.terminal)) + ) { + throw new Error( + 'Lighter Chase authoritative child observation regressed', + ); + } + child.observation = observed; + this.#totals(record); + } + + async #dispatch( + record: LighterChaseRecord, + journal: Journal, + dispatch: LighterChaseDispatch, + io: LighterChaseIo, + financialContinuation: boolean, + send: (hooks: LighterChaseDispatchHooks) => Promise, + ): Promise { + let accepted = false; + let signedQuote: + | Pick + | undefined; + const guard = (): void => { + io.assertCurrent(); + if (financialContinuation) { + this.#running(record); + const child = record.children.at(-1); + if ( + signedQuote && + (child?.price !== signedQuote.price || + child.size !== signedQuote.size || + child.quotedAt !== signedQuote.quotedAt) + ) { + throw new Error('Lighter Chase signed quote changed'); + } + const quotedAt = child?.quotedAt; + if ( + quotedAt === undefined || + io.now() < quotedAt || + io.now() - quotedAt > LIGHTER_CHASE_QUOTE_MAX_AGE_MS + ) { + throw new Error('Lighter Chase quote is stale before dispatch'); + } + if (io.now() >= record.intent.startedAt + record.intent.maxDurationMs) { + throw new Error('Lighter Chase duration elapsed before dispatch'); + } + } + }; + const hooks: LighterChaseDispatchHooks = { + prepareQuote: async (price) => { + io.assertCurrent(); + this.#running(record); + const child = record.children.at(-1); + if ( + !financialContinuation || + !child || + child.placement !== dispatch || + dispatch.phase !== 'prepared' + ) { + throw new Error('Lighter Chase quote cannot change after signing'); + } + if (io.now() < child.quotedAt) { + throw new Error('Lighter Chase quote clock moved backwards'); + } + if (io.now() >= record.intent.startedAt + record.intent.maxDurationMs) { + throw new Error('Lighter Chase duration elapsed before dispatch'); + } + this.#checkQuote(record, price, child.size); + child.price = price; + child.quotedAt = io.now(); + await this.#write(record.intent.owner, journal, io); + guard(); + }, + signed: async (identity) => { + guard(); + if ( + dispatch.phase !== 'prepared' || + !Number.isSafeInteger(identity.nonce) || + identity.nonce < 0 || + !isLighterTxHash(identity.txHash) || + !isLighterTxExpiry(identity.expiresAt) || + identity.expiresAt <= io.now() + ) { + throw new Error('Invalid Lighter Chase signing identity'); + } + if (financialContinuation) { + const child = record.children.at(-1); + if (!child) { + throw new Error('Lighter Chase signing child is missing'); + } + signedQuote = { + price: child.price, + size: child.size, + quotedAt: child.quotedAt, + }; + } + Object.assign(dispatch, identity, { phase: 'signed' }); + await this.#write(record.intent.owner, journal, io); + }, + beforeDispatch: async () => { + guard(); + if ( + dispatch.phase !== 'signed' || + (dispatch.expiresAt ?? 0) <= io.now() + ) { + throw new Error('Lighter Chase signed transaction is stale'); + } + dispatch.phase = 'attempted'; + await this.#write(record.intent.owner, journal, io); + guard(); + }, + notDispatched: async () => { + io.assertCurrent(); + if (dispatch.phase === 'acknowledged') { + throw new Error( + 'Acknowledged Lighter Chase dispatch cannot be unsent', + ); + } + dispatch.phase = 'failed'; + await this.#write(record.intent.owner, journal, io); + }, + accepted: () => { + accepted = true; + dispatch.acknowledged = true; + dispatch.phase = 'acknowledged'; + }, + afterAccepted: async () => this.#recordEvidence(record, dispatch, true), + }; + try { + guard(); + await send(hooks); + if (dispatch.phase !== 'attempted' && dispatch.phase !== 'acknowledged') { + throw new Error('Lighter Chase transport did not persist its dispatch'); + } + if (!accepted) { + hooks.accepted(); + await hooks.afterAccepted(); + } + io.assertCurrent(); + if (dispatch.phase !== 'acknowledged') { + throw new Error('Lighter Chase transport did not persist its dispatch'); + } + } catch (error) { + if (!accepted && dispatch.phase === 'attempted') { + await this.#recordEvidence(record, dispatch, false); + } + if (dispatch.phase === 'prepared' || dispatch.phase === 'signed') { + dispatch.phase = 'failed'; + await this.#write(record.intent.owner, journal, io); + } + throw error; + } + } + + #checkQuote(record: LighterChaseRecord, price: string, size: string): void { + validateWire(price, record.intent.priceDecimals, LIGHTER_MAX_ORDER_PRICE); + validateWire(size, record.intent.sizeDecimals, LIGHTER_MAX_BASE_AMOUNT); + const original = amount(record.intent.arrivalPrice); + const quote = amount(price); + const adverse = record.intent.isBuy + ? quote.minus(original) + : original.minus(quote); + if ( + adverse + .times(BASIS_POINTS_DIVISOR) + .gt(original.times(record.intent.maxDistanceBps)) + ) { + throw new Error('Lighter Chase maximum distance reached'); + } + const notional = quote.times(size); + if ( + amount(size).lt(record.intent.minBaseAmount) || + notional.lt(record.intent.minQuoteAmount) + ) { + throw new Error('Lighter Chase remainder is below venue minimum'); + } + if ( + amount(record.executedSize).plus(size).gt(record.intent.originalSize) || + amount(record.executedNotional) + .plus(notional) + .gt(record.intent.maxNotional) + ) { + throw new Error('Lighter Chase replacement exceeds aggregate budget'); + } + } + + async #place( + record: LighterChaseRecord, + journal: Journal, + price: string, + io: LighterChaseIo, + ): Promise { + this.#running(record); + this.#totals(record); + const size = amount(record.intent.originalSize) + .minus(record.executedSize) + .toFixed(); + this.#checkQuote(record, price, size); + if (!this.#settled(record)) { + throw new Error('Lighter Chase previous child remains unresolved'); + } + const clientOrderId = io.allocateClientId(); + if ( + !/^[1-9]\d*$/u.test(clientOrderId) || + BigInt(clientOrderId) > BigInt(LIGHTER_MAX_CLIENT_ORDER_INDEX) || + journal.records.some((entry) => + entry.children.some((child) => child.clientOrderId === clientOrderId), + ) + ) { + throw new Error('Lighter Chase client identity is invalid or reused'); + } + const child: LighterChaseChild = { + clientOrderId, + size, + price, + quotedAt: io.now(), + placement: { phase: 'prepared' }, + cancellations: [], + }; + record.children.push(child); + record.repricings = record.children.length - 1; + await this.#write(record.intent.owner, journal, io); + await this.#dispatch( + record, + journal, + child.placement, + io, + true, + async (hooks) => await io.place(child, hooks), + ); + try { + await this.#observe(record, child, io); + } catch (error) { + if (!(error instanceof LighterChaseObservationPendingError)) { + throw error; + } + record.error = error.message; + } + await this.#write(record.intent.owner, journal, io); + if (child.placement.phase === 'failed') { + this.#active.delete(record.intent.handle); + record.status = 'failed'; + } else if (child.observation?.terminal) { + this.#active.delete(record.intent.handle); + record.status = amount(record.executedSize).eq(record.intent.originalSize) + ? 'filled' + : 'failed'; + } + } + + async #awaitCancellationSettlement( + record: LighterChaseRecord, + journal: Journal, + child: LighterChaseChild, + io: Pick, + ): Promise { + const startedAt = io.now(); + const deadline = startedAt + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS; + const maxPolls = + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS / + LIGHTER_CHASE_CANCEL_SETTLEMENT_POLL_MS; + for (let poll = 0; poll <= maxPolls; poll += 1) { + io.assertCurrent(); + try { + await this.#observe(record, child, io); + } catch (error) { + io.assertCurrent(); + if (!(error instanceof LighterChaseObservationPendingError)) { + throw error; + } + } + // Observation also reconciles the retained dispatch. Persist phase + // changes and cumulative fills even while exact visibility is pending. + await this.#write(record.intent.owner, journal, io); + if ( + child.observation?.terminal || + child.cancellations.at(-1)?.phase === 'failed' + ) { + return; + } + const now = io.now(); + if (poll === maxPolls || now < startedAt || now >= deadline) { + return; + } + await new Promise((resolve) => + setTimeout( + resolve, + Math.min(LIGHTER_CHASE_CANCEL_SETTLEMENT_POLL_MS, deadline - now), + ), + ); + io.assertCurrent(); + } + } + + async #cancel( + record: LighterChaseRecord, + journal: Journal, + io: LighterChaseIo, + ): Promise { + const child = record.children.at(-1); + if (!child || child.placement.phase === 'failed') { + return; + } + let visibilityPending = false; + try { + await this.#observe(record, child, io); + } catch (error) { + io.assertCurrent(); + visibilityPending = error instanceof LighterChaseObservationPendingError; + // Mutable fill reads must not prevent exact immutable-child cleanup. + // The provider cancel path independently validates the owned child. + record.error = error instanceof Error ? error.message : String(error); + } + await this.#write(record.intent.owner, journal, io); + if (this.#settled(record) || child.observation?.terminal) { + return; + } + if (!child.observation && visibilityPending) { + throw new LighterChaseObservationPendingError( + 'Lighter Chase placement visibility remains pending', + ); + } + const previous = child.cancellations.at(-1); + if (previous && previous.phase !== 'failed') { + await this.#awaitCancellationSettlement(record, journal, child, io); + if (child.observation?.terminal) { + return; + } + throw new LighterChaseObservationPendingError( + 'Lighter Chase exact cancellation remains unresolved', + ); + } + if (child.cancellations.length >= LIGHTER_NATIVE_PROBE_CANCEL_LIMIT) { + throw new Error('Lighter Chase cancellation attempt limit reached'); + } + const dispatch: LighterChaseDispatch = { phase: 'prepared' }; + child.cancellations.push(dispatch); + await this.#write(record.intent.owner, journal, io); + let dispatchError: Error | undefined; + try { + await this.#dispatch( + record, + journal, + dispatch, + io, + false, + async (hooks) => await io.cancel(child, hooks), + ); + } catch (error) { + io.assertCurrent(); + dispatchError = error instanceof Error ? error : new Error(String(error)); + } + // A fill can win even when the cancellation transport rejects or loses + // its response. Always reread the exact child before deciding cleanup. + await this.#awaitCancellationSettlement(record, journal, child, io); + if (dispatchError && !child.observation?.terminal) { + throw dispatchError; + } + if (!child.observation?.terminal) { + throw new LighterChaseObservationPendingError( + 'Lighter Chase cancellation acknowledged but child is not terminal', + ); + } + } + + #requestStop(record: LighterChaseRecord, reason: ChaseOrderStatus): boolean { + this.#active.delete(record.intent.handle); + if ( + record.status !== 'active' && + record.status !== 'termination_pending' && + this.#settled(record) + ) { + this.#stopRequests.delete(record.intent.handle); + return false; + } + record.stopReason ??= + this.#stopRequests.get(record.intent.handle) ?? reason; + this.#stopRequests.delete(record.intent.handle); + record.status = 'termination_pending'; + return true; + } + + async #stop( + record: LighterChaseRecord, + journal: Journal, + io: LighterChaseIo, + reason: ChaseOrderStatus, + ): Promise { + if (!this.#requestStop(record, reason)) { + return; + } + await this.#write(record.intent.owner, journal, io); + try { + await this.#cancel(record, journal, io); + if (this.#settled(record)) { + record.status = record.stopReason === 'failed' ? 'failed' : 'canceled'; + if (amount(record.executedSize).eq(record.intent.originalSize)) { + record.status = 'filled'; + } + } + } catch (error) { + io.assertCurrent(); + record.error = error instanceof Error ? error.message : String(error); + } + await this.#write(record.intent.owner, journal, io); + } + + /** + * @param intent - Immutable bounded session intent. + * @param io - Provider-bound reads and existing guarded transport. + * @returns Exact owned placement state, including uncertain cleanup. + */ + async start( + intent: LighterChaseIntent, + io: LighterChaseIo, + ): Promise { + validateIntent(intent); + const captured = { ...intent, owner: { ...intent.owner } }; + const epoch = this.#epoch; + return await this.#locked(captured.owner, async () => { + const journal = await this.#read(captured.owner, io); + if (epoch !== this.#epoch) { + throw new Error('Lighter Chase start interrupted'); + } + if ( + journal.records.some( + (record) => !this.#settled(record) || record.status === 'active', + ) + ) { + throw new Error('An earlier Lighter Chase remains unresolved'); + } + if ( + journal.records.length >= LIGHTER_NATIVE_PROBE_RECORD_LIMIT || + journal.records.some( + (record) => record.intent.handle === captured.handle, + ) + ) { + throw new Error('Lighter Chase journal full or handle already tracked'); + } + const record: LighterChaseRecord = { + intent: captured, + children: [], + status: 'active', + repricings: 0, + lastTickAt: captured.startedAt, + executedSize: '0', + executedNotional: '0', + }; + journal.records.push(record); + await this.#write(captured.owner, journal, io); + this.#active.set(captured.handle, { epoch, symbol: captured.symbol }); + try { + const price = await io.quote(); + io.assertCurrent(); + this.#running(record); + await this.#place(record, journal, price, io); + if (record.status === 'active') { + this.#running(record); + } + } catch (error) { + this.#active.delete(captured.handle); + record.error = error instanceof Error ? error.message : String(error); + record.status = this.#settled(record) + ? 'failed' + : 'termination_pending'; + } + await this.#write(captured.owner, journal, io); + return record; + }); + } + + /** + * @param owner - Current account ownership. + * @param handle - Exact persisted handle. + * @param io - Original provider context; never recreated on reconnect. + * @returns Current state after at most one serial cancel/replace. + */ + async tick( + owner: LighterChaseOwner, + handle: string, + io: LighterChaseIo, + ): Promise { + return await this.#locked(owner, async () => { + const journal = await this.#read(owner, io); + const record = journal.records.find( + (entry) => entry.intent.handle === handle, + ); + if (!record) { + throw new Error('Unknown owned Lighter Chase handle'); + } + if ( + record.status !== 'active' || + this.#active.get(handle)?.epoch !== this.#epoch + ) { + this.#active.delete(handle); + return this.#visible(record); + } + const expired = (): boolean => + io.now() >= record.intent.startedAt + record.intent.maxDurationMs; + if (expired()) { + await this.#stop(record, journal, io, 'duration_reached'); + return record; + } + if ( + io.now() < record.lastTickAt || + io.now() - record.lastTickAt < record.intent.intervalMs + ) { + return record; + } + record.lastTickAt = io.now(); + if (record.repricings >= record.intent.maxRepricings) { + await this.#stop(record, journal, io, 'repricing_limit_reached'); + return record; + } + try { + const child = record.children.at(-1); + if (!child) { + throw new Error('Lighter Chase active child is missing'); + } + await this.#observe(record, child, io); + this.#running(record); + if (child.placement.phase === 'failed') { + this.#active.delete(handle); + record.status = 'failed'; + record.stopReason ??= 'failed'; + await this.#write(owner, journal, io); + return record; + } + await this.#write(owner, journal, io); + if ( + amount(record.executedSize).eq(record.intent.originalSize) && + child.observation?.terminal + ) { + this.#active.delete(handle); + record.status = 'filled'; + await this.#write(owner, journal, io); + return record; + } + if (expired()) { + await this.#stop(record, journal, io, 'duration_reached'); + return record; + } + const price = await io.quote(); + io.assertCurrent(); + this.#running(record); + if (expired()) { + await this.#stop(record, journal, io, 'duration_reached'); + return record; + } + const arrival = amount(record.intent.arrivalPrice); + const adverse = record.intent.isBuy + ? amount(price).minus(arrival) + : arrival.minus(amount(price)); + if ( + adverse + .times(BASIS_POINTS_DIVISOR) + .gt(arrival.times(record.intent.maxDistanceBps)) + ) { + await this.#stop(record, journal, io, 'max_distance_reached'); + return record; + } + if (amount(price).eq(child.price) && !child.observation?.terminal) { + return record; + } + await this.#cancel(record, journal, io); + this.#running(record); + if (amount(record.executedSize).eq(record.intent.originalSize)) { + this.#active.delete(handle); + record.status = 'filled'; + } else { + const freshPrice = await io.quote(); + io.assertCurrent(); + this.#running(record); + await this.#place(record, journal, freshPrice, io); + } + await this.#write(owner, journal, io); + } catch (error) { + io.assertCurrent(); + record.error = error instanceof Error ? error.message : String(error); + if ( + error instanceof LighterChaseObservationPendingError && + this.#active.get(handle)?.epoch === this.#epoch && + !expired() + ) { + await this.#write(owner, journal, io); + } else { + await this.#stop( + record, + journal, + io, + expired() ? 'duration_reached' : 'failed', + ); + } + } + return record; + }); + } + + /** + * @param owner - Current account scope. + * @param handle - Exact durable session handle. + * @param io - Existing current authority, never another account or slot. + * @param reason - Requested visible terminal reason. + * @returns Terminal state only with exact child evidence; otherwise pending. + */ + async stop( + owner: LighterChaseOwner, + handle: string, + io: LighterChaseIo, + reason: ChaseOrderStatus, + ): Promise { + if (this.#active.has(handle) && !this.#stopRequests.has(handle)) { + this.#stopRequests.set(handle, reason); + } + this.#active.delete(handle); + return await this.#locked(owner, async () => { + const journal = await this.#read(owner, io); + const record = journal.records.find( + (entry) => entry.intent.handle === handle, + ); + if (!record) { + throw new Error('Unknown owned Lighter Chase handle'); + } + await this.#stop(record, journal, io, reason); + return record; + }); + } + + /** + * Settle one original cancellation using observation only, even if its phase + * becomes failed. This path cannot allocate, sign or dispatch another attempt. + * + * @param owner - Exact original wallet/network/account/key owner. + * @param handle - Durable Chase handle whose stop was already requested. + * @param clientOrderId - Exact latest child selected by the caller. + * @param identity - Original cancellation transaction identity. + * @param io - Current observation authority with no financial transport. + * @returns Proven terminal state or retained unresolved cleanup. + */ + async reconcileCancellation( + owner: LighterChaseOwner, + handle: string, + clientOrderId: string, + identity: ReconcileChaseOrderCancellationParams['cancellation'], + io: Pick, + ): Promise { + const capturedOwner = { ...owner }; + const capturedIdentity = { ...identity }; + return await this.#locked(capturedOwner, async () => { + const journal = await this.#read(capturedOwner, io); + const record = journal.records.find( + (entry) => entry.intent.handle === handle, + ); + const child = record?.children.at(-1); + const cancellation = child?.cancellations.at(-1); + if ( + !record || + !child || + !cancellation || + record.intent.owner.apiKeyIndex !== capturedOwner.apiKeyIndex || + record.status === 'active' || + record.stopReason === undefined || + child.clientOrderId !== clientOrderId || + !['attempted', 'acknowledged', 'failed'].includes(cancellation.phase) || + !Number.isSafeInteger(capturedIdentity.nonce) || + capturedIdentity.nonce < 0 || + !isLighterTxHash(capturedIdentity.txHash) || + !isLighterTxExpiry(capturedIdentity.expiresAt) || + cancellation.nonce !== capturedIdentity.nonce || + cancellation.txHash !== capturedIdentity.txHash || + cancellation.expiresAt !== capturedIdentity.expiresAt + ) { + throw new Error( + 'Lighter Chase original cancellation ownership or identity mismatch', + ); + } + if (!this.#requestStop(record, 'canceled')) { + return record; + } + await this.#write(capturedOwner, journal, io); + try { + await this.#awaitCancellationSettlement(record, journal, child, io); + if (this.#settled(record)) { + if (amount(record.executedSize).eq(record.intent.originalSize)) { + record.status = 'filled'; + } else { + record.status = + record.stopReason === 'failed' ? 'failed' : 'canceled'; + } + } + } catch (error) { + io.assertCurrent(); + record.error = error instanceof Error ? error.message : String(error); + } + await this.#write(capturedOwner, journal, io); + return record; + }); + } + + /** + * Persist stop intent when venue authority is unavailable, retaining every dispatch. + * + * @param owner - Remembered account scope. + * @param handle - Exact durable session handle. + * @param io - Current wallet/network and local storage authority only. + * @param reason - Requested stop cause; the first cause remains authoritative. + * @param error - Explanation of the unresolved venue cleanup. + * @returns Existing terminal evidence or pending local cleanup, without cancellation. + */ + async stopLocally( + owner: LighterChaseOwner, + handle: string, + io: Pick, + reason: ChaseOrderStatus, + error: string, + ): Promise { + if (this.#active.has(handle) && !this.#stopRequests.has(handle)) { + this.#stopRequests.set(handle, reason); + } + this.#active.delete(handle); + return await this.#locked(owner, async () => { + const journal = await this.#read(owner, io); + const record = journal.records.find( + (entry) => entry.intent.handle === handle, + ); + if (!record) { + throw new Error('Unknown owned Lighter Chase handle'); + } + if (this.#requestStop(record, reason)) { + record.error = error; + await this.#write(owner, journal, io); + } + return record; + }); + } + + /** + * Retain a scheduler failure without using any financial transport. + * + * @param owner - Immutable account ownership. + * @param handle - Exact session handle. + * @param io - Current read and storage authority. + * @param error - Visible failure reason. + */ + async recordError( + owner: LighterChaseOwner, + handle: string, + io: Pick, + error: string, + ): Promise { + this.#active.delete(handle); + await this.#locked(owner, async () => { + const journal = await this.#read(owner, io); + const record = journal.records.find( + (entry) => entry.intent.handle === handle, + ); + if ( + record && + (record.status === 'active' || record.status === 'termination_pending') + ) { + record.error = error; + record.status = 'termination_pending'; + record.stopReason ??= 'failed'; + await this.#write(owner, journal, io); + } + }); + } + + #visible(record: LighterChaseRecord): LighterChaseRecord { + if ( + record.status === 'active' && + this.#active.get(record.intent.handle)?.epoch !== this.#epoch + ) { + record.status = 'termination_pending'; + } + return record; + } + + /** + * Inspect one validated durable record without retiring unsent attempts. + * + * @param owner - Wallet/network/account storage scope, not current venue authority. + * @param handle - Exact opaque stable handle. + * @param io - Read-only context fence. + * @returns Complete durable record, or undefined when absent. Corruption rejects. + */ + async inspect( + owner: LighterChaseOwner, + handle: string, + io: Pick, + ): Promise { + return await this.#locked(owner, async () => { + const journal = await this.#read(owner, io); + const record = journal.records.find( + (entry) => entry.intent.handle === handle, + ); + return record ? this.#visible(record) : undefined; + }); + } + + /** + * Read recorded child identities without the Chase loop/journal lock or writes. + * A venue-lock caller must not wait on the loop, which can itself await that + * venue lock. Each storage read validates the complete durable snapshot. + * + * @param owner - Captured account ownership. + * @param clientOrderId - Exact target client ID. + * @param io - Session fence. + * @returns Whether this ID belongs to any retained Chase child. + */ + async hasRecordedChild( + owner: LighterChaseOwner, + clientOrderId: string, + io: Pick, + ): Promise { + const journal = await this.#read(owner, io); + return journal.records.some((record) => + record.children.some((child) => child.clientOrderId === clientOrderId), + ); + } + + /** + * @param owner - Account-scoped durable inventory. + * @param io - Read authority and ownership fence only; no transport is used. + * @returns Visible owned state without automatic financial continuation. + */ + async list( + owner: LighterChaseOwner, + io: Pick, + ): Promise { + return await this.#locked(owner, async () => { + const journal = await this.#read(owner, io); + let changed = false; + for (const record of journal.records) { + let recordChanged = false; + for (const child of record.children) { + for (const dispatch of [child.placement, ...child.cancellations]) { + if (dispatch.phase === 'prepared' || dispatch.phase === 'signed') { + if (dispatch === child.placement && child.observation) { + throw new Error( + 'Lighter Chase unsent child has conflicting venue evidence', + ); + } + // Account lock excludes in-flight dispatch; attempted is durable + // before transport, so these persisted phases are provably unsent. + dispatch.phase = 'failed'; + changed = true; + recordChanged = true; + } + } + } + if (recordChanged && this.#settled(record)) { + record.status = amount(record.executedSize).eq( + record.intent.originalSize, + ) + ? 'filled' + : 'failed'; + this.#active.delete(record.intent.handle); + } + } + if (changed) { + await this.#write(owner, journal, io); + } + return journal.records.map((record) => this.#visible(record)); + }); + } +} + +/** + * @param record - Validated durable session observation. + * @returns Existing provider-bound public Chase management contract. + */ +export function toLighterChaseOrder(record: LighterChaseRecord): ChaseOrder { + const { intent } = record; + const child = record.children.at(-1); + const price = child?.price ?? intent.arrivalPrice; + const arrival = amount(intent.arrivalPrice); + const adverse = intent.isBuy + ? amount(price).minus(arrival) + : arrival.minus(amount(price)); + return { + handle: intent.handle, + symbol: intent.symbol, + side: intent.isBuy ? 'buy' : 'sell', + originalSize: intent.originalSize, + remainingSize: amount(intent.originalSize) + .minus(record.executedSize) + .toFixed(), + arrivalPrice: intent.arrivalPrice, + restingPrice: price, + restingOrderId: child?.observation?.terminal + ? null + : (child?.observation?.orderId ?? null), + distanceChasedBps: BigNumber.maximum( + 0, + adverse.div(arrival).times(BASIS_POINTS_DIVISOR), + ) + .integerValue(BigNumber.ROUND_HALF_UP) + .toNumber(), + maxDistanceBps: intent.maxDistanceBps, + repricings: record.repricings, + startedAt: intent.startedAt, + status: record.status, + providerId: 'lighter', + }; +} diff --git a/packages/perps-controller/src/services/LighterClientService.ts b/packages/perps-controller/src/services/LighterClientService.ts index fd0734a1143..fcb5e1314f6 100644 --- a/packages/perps-controller/src/services/LighterClientService.ts +++ b/packages/perps-controller/src/services/LighterClientService.ts @@ -30,6 +30,12 @@ import { union, } from '@metamask/superstruct'; +import { + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_MAX_CLIENT_ORDER_INDEX, + LIGHTER_MAX_ORDER_ID, + LIGHTER_CLIENT_ORDER_LOOKUP_LIMIT, +} from '../constants/lighterConfig.js'; import { getLighterHttpEndpoint, LIGHTER_DATA_INTEGRITY_PREFIX, @@ -40,11 +46,13 @@ import type { LighterAccountResponse, LighterAccountsByL1AddressResponse, LighterActiveOrdersResponse, + LighterEditableOrdersResponse, LighterApiKeysResponse, LighterNetwork, LighterNextNonceResponse, LighterTxLookupResponse, LighterOrderBookMeta, + LighterOrderBookOrdersResponse, LighterOrderBookDetailsResponse, LighterOrderBooksResponse, LighterCandlesResponse, @@ -59,6 +67,7 @@ import type { LighterTransferHistoryResponse, LighterWithdrawHistoryResponse, } from '../types/lighter-types.js'; +import { parseLighterLosslessJson } from '../utils/lighterLosslessJson.js'; /** * Duration market metadata stays cached before a refetch. @@ -135,6 +144,9 @@ const PositionStruct = type({ symbol: string(), initialMarginFraction: NonNegativeDecimalStringStruct, openOrderCount: NonNegativeIntegerStruct, + pendingOrderCount: optional(NonNegativeIntegerStruct), + positionTiedOrderCount: optional(NonNegativeIntegerStruct), + allocatedMargin: optional(NonNegativeDecimalStringStruct), sign: SafeIntegerStruct, position: NonNegativeDecimalStringStruct, avgEntryPrice: NonNegativeDecimalStringStruct, @@ -231,31 +243,63 @@ const MarketDetailStruct = refine( 'positive margin fractions for tradable markets', hasValidMarketMarginFractions, ); -const OrderStruct = type({ - orderIndex: NonNegativeIntegerStruct, - clientOrderIndex: NonNegativeIntegerStruct, - marketIndex: NonNegativeIntegerStruct, - ownerAccountIndex: NonNegativeIntegerStruct, - initialBaseAmount: PositiveDecimalStringStruct, - remainingBaseAmount: NonNegativeDecimalStringStruct, - filledBaseAmount: optional(NonNegativeDecimalStringStruct), - price: PositiveDecimalStringStruct, - isAsk: boolean(), - type: string(), - timeInForce: string(), - reduceOnly: union([FiniteNumberStruct, boolean()]), - status: string(), - orderExpiry: SafeIntegerStruct, - timestamp: NonNegativeIntegerStruct, - triggerPrice: optional(NonNegativeDecimalStringStruct), - orderId: optional(string()), - parentOrderIndex: optional(NonNegativeIntegerStruct), - parentOrderId: optional(string()), - toCancelOrderId0: optional(string()), - toTriggerOrderId0: optional(string()), - toTriggerOrderId1: optional(string()), -}); +const OrderStruct = refine( + type({ + orderIndex: NonNegativeIntegerStruct, + clientOrderIndex: NonNegativeIntegerStruct, + marketIndex: NonNegativeIntegerStruct, + ownerAccountIndex: NonNegativeIntegerStruct, + initialBaseAmount: NonNegativeDecimalStringStruct, + remainingBaseAmount: NonNegativeDecimalStringStruct, + filledBaseAmount: optional(NonNegativeDecimalStringStruct), + filledQuoteAmount: optional(NonNegativeDecimalStringStruct), + clientOrderId: optional(string()), + nonce: optional(SafeIntegerStruct), + createdAt: optional(NonNegativeIntegerStruct), + updatedAt: optional(NonNegativeIntegerStruct), + transactionTime: optional(NonNegativeIntegerStruct), + price: PositiveDecimalStringStruct, + isAsk: boolean(), + type: string(), + timeInForce: string(), + reduceOnly: union([FiniteNumberStruct, boolean()]), + status: string(), + orderExpiry: SafeIntegerStruct, + timestamp: NonNegativeIntegerStruct, + triggerPrice: optional(NonNegativeDecimalStringStruct), + orderId: optional(string()), + parentOrderIndex: optional(NonNegativeIntegerStruct), + parentOrderId: optional(string()), + toCancelOrderId0: optional(string()), + toTriggerOrderId0: optional(string()), + toTriggerOrderId1: optional(string()), + }), + 'positive order size or explicitly linked zero-size reduce-only trigger', + (order) => { + if (Number(order.initialBaseAmount) > 0) { + return true; + } + const hasParent = + (order.parentOrderIndex !== undefined && order.parentOrderIndex > 0) || + (typeof order.parentOrderId === 'string' && + /^[1-9]\d*$/u.test(order.parentOrderId)); + return ( + hasParent && + (order.reduceOnly === 1 || order.reduceOnly === true) && + [ + 'stop-loss', + 'stop-loss-limit', + 'take-profit', + 'take-profit-limit', + ].includes(order.type) && + Number(order.triggerPrice) > 0 + ); + }, +); const TradeStruct = type({ + tradeIdStr: optional(string()), + bidIdStr: optional(string()), + askIdStr: optional(string()), tradeId: NonNegativeIntegerStruct, txHash: string(), type: string(), @@ -279,7 +323,49 @@ const TradeStruct = type({ makerPositionSignChanged: optional(boolean()), }); +const BookOrderStruct = type({ + orderIndex: NonNegativeIntegerStruct, + orderId: string(), + ownerAccountIndex: NonNegativeIntegerStruct, + initialBaseAmount: PositiveDecimalStringStruct, + remainingBaseAmount: PositiveDecimalStringStruct, + price: PositiveDecimalStringStruct, + orderExpiry: NonNegativeIntegerStruct, + transactionTime: NonNegativeIntegerStruct, +}); + +const LosslessOrderIndexStruct = define( + 'exact nonnegative native order identity', + (value) => { + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0; + } + return ( + typeof value === 'string' && + /^(?:0|[1-9]\d*)$/u.test(value) && + value.length <= 19 && + BigInt(value) <= BigInt(LIGHTER_MAX_ORDER_ID) + ); + }, +); +const EditableOrderStruct = type({ + ...OrderStruct.schema, + orderIndex: LosslessOrderIndexStruct, + parentOrderIndex: optional(LosslessOrderIndexStruct), +}); +const EditableOrdersStruct = type({ + ...BaseResponseStruct.schema, + orders: array(EditableOrderStruct), +}); + const ResponseStructs = { + orderBookOrders: type({ + code: SafeIntegerStruct, + totalBids: NonNegativeIntegerStruct, + totalAsks: NonNegativeIntegerStruct, + bids: array(BookOrderStruct), + asks: array(BookOrderStruct), + }), orderBooks: type({ ...BaseResponseStruct.schema, orderBooks: array(MarketStruct), @@ -451,17 +537,30 @@ function toCamelKey(key: string): string { * follow camelCase conventions (see types/lighter-types.ts). * * @param value - Parsed JSON value. + * @param rejectCollisions - Refuse distinct wire keys that camelize to one identity. * @returns The value with camelCase keys. */ -export function convertKeysToCamelCase(value: unknown): unknown { +export function convertKeysToCamelCase( + value: unknown, + rejectCollisions = false, +): unknown { if (Array.isArray(value)) { - return value.map(convertKeysToCamelCase); + return value.map((entry) => + convertKeysToCamelCase(entry, rejectCollisions), + ); } if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record); + if ( + rejectCollisions && + new Set(entries.map(([key]) => toCamelKey(key))).size !== entries.length + ) { + throw new Error('Ambiguous Lighter response keys'); + } return Object.fromEntries( - Object.entries(value as Record).map(([key, entry]) => [ + entries.map(([key, entry]) => [ toCamelKey(key), - convertKeysToCamelCase(entry), + convertKeysToCamelCase(entry, rejectCollisions), ]), ); } @@ -531,6 +630,24 @@ export class LighterClientService { return response.orderBooks; } + /** + * Read a new public native order book snapshot without a local cache. + * + * @param marketId - Native signed int16 market ID. + * @returns Up to 250 individual orders on each side. + */ + async getOrderBookOrders( + marketId: number, + ): Promise { + if (!Number.isSafeInteger(marketId) || marketId < 0 || marketId > 32767) { + throw new Error('Invalid Lighter market ID'); + } + return await this.#get( + `/api/v1/orderBookOrders?market_id=${marketId}&limit=250`, + ResponseStructs.orderBookOrders, + ); + } + /** * Fetch market stats for all markets. * @@ -654,6 +771,85 @@ export class LighterClientService { ); } + /** + * Fetch exact client IDs. Venue retention is the last 10K active orders or + * last 1K inactive orders within 24 hours; omission is not absence proof. + * + * @param accountIndex - Explicit verified account owner. + * @param authToken - Matching account read authority. + * @param clientOrderIds - Unique canonical 48-bit client IDs, at most twenty. + * @returns Matching rows, subject to venue retention limits. + */ + async getOrdersByClientIds( + accountIndex: number, + authToken: string, + clientOrderIds: readonly string[], + ): Promise { + if (!Number.isSafeInteger(accountIndex) || accountIndex < 0) { + throw new Error('Invalid Lighter account index'); + } + if ( + clientOrderIds.length === 0 || + clientOrderIds.length > LIGHTER_CLIENT_ORDER_LOOKUP_LIMIT || + new Set(clientOrderIds).size !== clientOrderIds.length || + clientOrderIds.some( + (id) => + typeof id !== 'string' || + !/^[1-9]\d*$/u.test(id) || + BigInt(id) > BigInt(LIGHTER_MAX_CLIENT_ORDER_INDEX), + ) + ) { + throw new Error('Invalid Lighter client order IDs'); + } + return await this.#get( + `/api/v1/accountOrders?account_index=${accountIndex}&client_order_indexes=${encodeURIComponent(clientOrderIds.join(','))}`, + ResponseStructs.activeOrders, + { authorization: authToken }, + ); + } + + /** + * Read active targets or exact client-correlated lifecycle rows without rounding int64 venue IDs. + * Uses the saved accountOrders contract, which accepts client IDs, not venue order IDs. + * + * @param accountIndex - Verified owner account. + * @param authToken - Existing registered read authority. + * @param clientOrderIds - Exact client identities once an active target is known. + * @returns Validated lossless order observations. Missing rows are never terminal proof. + */ + async getEditableOrders( + accountIndex: number, + authToken: string, + clientOrderIds?: readonly string[], + ): Promise { + if ( + !Number.isSafeInteger(accountIndex) || + accountIndex < 0 || + accountIndex > Number(LIGHTER_MAX_BASE_AMOUNT) || + (clientOrderIds && + (clientOrderIds.length === 0 || + clientOrderIds.length > LIGHTER_CLIENT_ORDER_LOOKUP_LIMIT || + new Set(clientOrderIds).size !== clientOrderIds.length || + clientOrderIds.some( + (id) => + !/^[1-9]\d*$/u.test(id) || + id.length > 15 || + BigInt(id) > BigInt(LIGHTER_MAX_BASE_AMOUNT), + ))) + ) { + throw new Error('Invalid Lighter editable-order identity'); + } + const path = clientOrderIds + ? `/api/v1/accountOrders?account_index=${accountIndex}&client_order_indexes=${encodeURIComponent(clientOrderIds.join(','))}` + : `/api/v1/accountActiveOrders?account_index=${accountIndex}`; + return await this.#request( + path, + { method: 'GET', headers: { authorization: authToken } }, + EditableOrdersStruct, + true, + ); + } + /** * Fetch historical (inactive) orders: filled and canceled lifecycle * states, newest first (auth token required). @@ -752,12 +948,22 @@ export class LighterClientService { authToken: string, query: LighterTradesQuery, ): Promise { - const { limit, cursor, from, marketId } = query; + const { limit, cursor, from, marketId, orderIndex, aggregate } = query; + if ( + orderIndex !== undefined && + (typeof orderIndex !== 'string' || + !/^[1-9]\d*$/u.test(orderIndex) || + BigInt(orderIndex) > BigInt(LIGHTER_MAX_ORDER_ID)) + ) { + throw new Error('Invalid Lighter exact trade order index'); + } return await this.#get( `/api/v1/trades?sort_by=timestamp&sort_dir=desc&limit=${limit}&account_index=${accountIndex}&market_type=perp${ cursor === undefined ? '' : `&cursor=${encodeURIComponent(cursor)}` }${from === undefined ? '' : `&from=${from}`}${ marketId === undefined ? '' : `&market_id=${marketId}` + }${orderIndex === undefined ? '' : `&order_index=${orderIndex}`}${ + aggregate === undefined ? '' : `&aggregate=${String(aggregate)}` }`, ResponseStructs.trades, { authorization: authToken }, @@ -872,6 +1078,7 @@ export class LighterClientService { path: string, init: { method: string; headers?: Record; body?: string }, responseStruct: Struct, + lossless = false, ): Promise => { const url = `${this.baseUrl}${path}`; const controller = new AbortController(); @@ -888,7 +1095,12 @@ export class LighterClientService { signal: controller.signal, }); - const payload: unknown = convertKeysToCamelCase(await response.json()); + const payload: unknown = convertKeysToCamelCase( + lossless + ? parseLighterLosslessJson(await response.text()) + : await response.json(), + lossless, + ); assert(payload, BaseResponseStruct); // Lighter returns HTTP 200 with an application-level error code, and diff --git a/packages/perps-controller/src/services/LighterTwapService.ts b/packages/perps-controller/src/services/LighterTwapService.ts new file mode 100644 index 00000000000..facb5f22c04 --- /dev/null +++ b/packages/perps-controller/src/services/LighterTwapService.ts @@ -0,0 +1,731 @@ +import { + assert, + type, + array, + string, + number, + boolean, + enums, + optional, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; + +import { + LIGHTER_MAX_MARKET_ID, + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_MAX_CLIENT_ORDER_INDEX, + LIGHTER_MAX_ORDER_PRICE, + LIGHTER_MAX_DECIMALS, + LIGHTER_MINUTE_MS, + LIGHTER_NATIVE_PROBE_RECORD_LIMIT, + LIGHTER_NATIVE_PROBE_CANCEL_LIMIT, + LIGHTER_NATIVE_PROBE_PAGE_LIMIT, + LIGHTER_NATIVE_PROBE_PAGE_SIZE, + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_TRADING_API_KEY_INDEX, + LIGHTER_TX_EXPIRY_SLACK_MS, + getLighterTransactionOutcome, +} from '../constants/lighterConfig.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; +import type { + LighterApiOrder, + LighterRestTrade, +} from '../types/lighter-types.js'; +import { reconcileLighterTwapObservation } from '../utils/lighterTwapReconciliation.js'; +import type { LighterTwapObservation } from '../utils/lighterTwapReconciliation.js'; +import type { LighterClientService } from './LighterClientService.js'; +import { + isLighterTxHash, + isLighterTxExpiry, +} from './lighterDispatchIdentity.js'; + +const OwnerStruct = type({ + wallet: string(), + network: enums(['testnet', 'mainnet']), + accountIndex: number(), + apiKeyIndex: number(), +}); +const IntentStruct = type({ + owner: OwnerStruct, + symbol: string(), + marketId: number(), + clientOrderId: string(), + size: string(), + price: string(), + isBuy: boolean(), + reduceOnly: boolean(), + sizeDecimals: number(), + priceDecimals: number(), + durationMinutes: number(), + startedAt: number(), + orderExpiry: number(), +}); +const DispatchStruct = type({ + phase: enums([ + 'prepared', + 'signed', + 'attempted', + 'acknowledged', + 'succeeded', + 'failed', + ]), + nonce: optional(number()), + txHash: optional(string()), + expiresAt: optional(number()), +}); +const RecordStruct = type({ + intent: IntentStruct, + placement: DispatchStruct, + cancellations: array(DispatchStruct), + terminalConfirmed: boolean(), + parentOrderId: optional(string()), + terminalEvidence: optional( + type({ parentOrderId: string(), status: string(), observedAt: number() }), + ), +}); +const JournalStruct = type({ + version: enums([1]), + records: array(RecordStruct), +}); + +export type LighterTwapOwner = Infer; +export type LighterTwapIntent = Infer; +export type LighterTwapDispatch = Infer; +export type LighterTwapRecord = Infer; +export type LighterTwapDispatchIdentity = { + nonce: number; + txHash: string; + expiresAt: number; +}; +export type LighterTwapDispatchHooks = { + signed: (identity: LighterTwapDispatchIdentity) => Promise; + beforeDispatch: () => Promise; + notDispatched: () => Promise; +}; +type Send = (hooks: LighterTwapDispatchHooks) => Promise; +type Journal = Infer; +export type LighterTwapReadObservation = { + record: LighterTwapRecord; + orders: LighterApiOrder[]; + trades: LighterRestTrade[]; + observation?: LighterTwapObservation; + issue?: string; +}; +type ReadClient = Pick< + LighterClientService, + | 'getOrdersByClientIds' + | 'getActiveOrders' + | 'getInactiveOrders' + | 'getTrades' + | 'getTx' +>; + +const queues = new Map>(); + +/** + * @param row - Venue row with numeric and optional exact client ID. + * @param clientOrderId - Persisted canonical client identity. + * @returns Whether both available counterparts identify this schedule. + */ +function matchesClientId(row: LighterApiOrder, clientOrderId: string): boolean { + if ( + !Number.isSafeInteger(row.clientOrderIndex) || + row.clientOrderIndex < 0 || + (row.clientOrderId !== undefined && + row.clientOrderId !== String(row.clientOrderIndex)) + ) { + throw new Error('Lighter TWAP order identity is unsafe or inconsistent'); + } + return String(row.clientOrderIndex) === clientOrderId; +} + +/** + * Validate immutable venue ownership and exact native intent, including stored + * records. Storage corruption must never become an empty inventory. + * + * @param intent - New or persisted immutable intent. + */ +function validateIntent(intent: LighterTwapIntent): void { + assert(intent, IntentStruct); + const { owner } = intent; + if ( + !owner.wallet || + owner.wallet !== owner.wallet.toLowerCase() || + !Number.isSafeInteger(owner.accountIndex) || + owner.accountIndex < 0 || + !Number.isSafeInteger(owner.apiKeyIndex) || + owner.apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + owner.apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !Number.isSafeInteger(intent.marketId) || + intent.marketId < 0 || + intent.marketId > LIGHTER_MAX_MARKET_ID || + !/^[1-9]\d*$/u.test(intent.clientOrderId) || + BigInt(intent.clientOrderId) > BigInt(LIGHTER_MAX_CLIENT_ORDER_INDEX) || + !intent.symbol || + !Number.isSafeInteger(intent.startedAt) || + intent.startedAt <= 0 || + !Number.isSafeInteger(intent.durationMinutes) || + intent.durationMinutes <= 0 || + !Number.isSafeInteger(intent.orderExpiry) || + intent.orderExpiry !== + intent.startedAt + intent.durationMinutes * LIGHTER_MINUTE_MS + ) { + throw new Error('Invalid Lighter TWAP ownership or immutable intent'); + } + for (const [value, decimals, maximum] of [ + [intent.size, intent.sizeDecimals, LIGHTER_MAX_BASE_AMOUNT], + [intent.price, intent.priceDecimals, LIGHTER_MAX_ORDER_PRICE], + ] as const) { + const integer = new BigNumber(value).shiftedBy(decimals); + if ( + !/^\d+(?:\.\d+)?$/u.test(value) || + !Number.isInteger(decimals) || + decimals < 0 || + decimals > LIGHTER_MAX_DECIMALS || + !integer.isInteger() || + integer.lt(1) || + integer.gt(maximum) + ) { + throw new Error('Invalid Lighter TWAP exact wire intent'); + } + } +} + +/** + * @param owner - Durable scope. + * @returns A delimiter-safe local journal key. + */ +function ownerKey(owner: LighterTwapOwner): string { + return `lighterNativeTwap:${JSON.stringify([owner.network, owner.wallet, owner.accountIndex])}`; +} + +/** Durable native schedules. Dispatch is supplied by the existing venue lock. */ +export class LighterTwapService { + readonly #storage: Pick< + PerpsPlatformDependencies['diskCache'], + 'getItem' | 'setItem' + >; + readonly #assertCurrent: (owner: LighterTwapOwner) => void; + readonly #terminalMappingVerified: boolean; + + constructor(options: { + storage: Pick< + PerpsPlatformDependencies['diskCache'], + 'getItem' | 'setItem' + >; + assertCurrent: (owner: LighterTwapOwner) => void; + /** Internal rollout gate; the provider keeps this false pending venue proof. */ + terminalMappingVerified?: boolean; + }) { + this.#storage = options.storage; + this.#assertCurrent = options.assertCurrent; + this.#terminalMappingVerified = options.terminalMappingVerified ?? false; + } + + async #locked( + owner: LighterTwapOwner, + action: () => Promise, + ): Promise { + const key = ownerKey(owner); + const previous = queues.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then(action); + queues.set(key, pending); + try { + return await pending; + } finally { + if (queues.get(key) === pending) { + queues.delete(key); + } + } + } + + async #read(owner: LighterTwapOwner): Promise { + this.#assertCurrent(owner); + const raw = await this.#storage.getItem(ownerKey(owner)); + this.#assertCurrent(owner); + if (raw === null) { + return { version: 1, records: [] }; + } + const parsed: unknown = JSON.parse(raw); + assert(parsed, JournalStruct); + const seen = new Set(); + if (parsed.records.length > LIGHTER_NATIVE_PROBE_RECORD_LIMIT) { + throw new Error('Lighter TWAP journal capacity exceeded'); + } + for (const record of parsed.records) { + validateIntent(record.intent); + if ( + ownerKey(record.intent.owner) !== ownerKey(owner) || + seen.has(record.intent.clientOrderId) + ) { + throw new Error('Lighter TWAP persisted ownership mismatch'); + } + seen.add(record.intent.clientOrderId); + if ( + record.terminalConfirmed && + (!record.terminalEvidence || + record.terminalEvidence.parentOrderId !== record.parentOrderId || + !['filled', 'canceled', 'canceled-expired'].includes( + record.terminalEvidence.status, + ) || + !Number.isSafeInteger(record.terminalEvidence.observedAt) || + record.placement.phase !== 'succeeded') + ) { + throw new Error('Lighter TWAP terminal ownership evidence is invalid'); + } + for (const dispatch of [record.placement, ...record.cancellations]) { + if (dispatch.phase !== 'prepared' && dispatch.phase !== 'failed') { + if ( + dispatch.nonce === undefined || + dispatch.txHash === undefined || + dispatch.expiresAt === undefined + ) { + throw new Error('Lighter TWAP persisted dispatch identity missing'); + } + this.#validateIdentity({ + nonce: dispatch.nonce, + txHash: dispatch.txHash, + expiresAt: dispatch.expiresAt, + }); + } + } + } + return parsed; + } + + async #write(owner: LighterTwapOwner, journal: Journal): Promise { + this.#assertCurrent(owner); + await this.#storage.setItem(ownerKey(owner), JSON.stringify(journal)); + this.#assertCurrent(owner); + } + + #validateIdentity(identity: LighterTwapDispatchIdentity): void { + if ( + !Number.isSafeInteger(identity.nonce) || + identity.nonce < 0 || + !isLighterTxHash(identity.txHash) || + !isLighterTxExpiry(identity.expiresAt) + ) { + throw new Error('Invalid Lighter TWAP signed dispatch identity'); + } + } + + async #dispatch( + owner: LighterTwapOwner, + journal: Journal, + dispatch: LighterTwapDispatch, + send: Send, + ): Promise { + const hooks: LighterTwapDispatchHooks = { + signed: async (identity) => { + this.#validateIdentity(identity); + if (dispatch.phase !== 'prepared') { + throw new Error('Lighter TWAP dispatch already signed'); + } + Object.assign(dispatch, identity, { phase: 'signed' }); + await this.#write(owner, journal); + }, + beforeDispatch: async () => { + this.#assertCurrent(owner); + if ( + dispatch.phase !== 'signed' || + (dispatch.expiresAt ?? 0) <= Date.now() + ) { + throw new Error('Lighter TWAP signed dispatch is stale or missing'); + } + dispatch.phase = 'attempted'; + await this.#write(owner, journal); + }, + notDispatched: async () => { + if ( + dispatch.phase === 'acknowledged' || + dispatch.phase === 'succeeded' + ) { + throw new Error( + 'Lighter TWAP acknowledged dispatch cannot become unsent', + ); + } + dispatch.phase = 'failed'; + await this.#write(owner, journal); + }, + }; + try { + await send(hooks); + if (dispatch.phase !== 'attempted') { + throw new Error('Lighter TWAP transport did not record dispatch'); + } + dispatch.phase = 'acknowledged'; + await this.#write(owner, journal); + } catch (error) { + if (dispatch.phase === 'prepared' || dispatch.phase === 'signed') { + dispatch.phase = 'failed'; + await this.#write(owner, journal); + } + // Attempted records survive ambiguous transport errors and restart. + throw error; + } + } + + /** + * @param owner - Current wallet/network/account scope. + * @returns Durable records across every original signing slot, without writes. + */ + async list(owner: LighterTwapOwner): Promise { + return await this.#locked( + owner, + async () => (await this.#read(owner)).records, + ); + } + + /** + * @param intent - Immutable exact schedule intent, persisted before signing. + * @param send - Existing venue-lock transport with durable pre-send hooks. + * @returns Recorded acknowledgment, which is not schedule completion. + */ + async place( + intent: LighterTwapIntent, + send: Send, + ): Promise { + validateIntent(intent); + // Copy before the first await so caller mutation cannot change ownership. + const frozenIntent: LighterTwapIntent = { + ...intent, + owner: { ...intent.owner }, + }; + return await this.#locked(frozenIntent.owner, async () => { + const journal = await this.#read(frozenIntent.owner); + if ( + journal.records.some( + (record) => + record.intent.clientOrderId === frozenIntent.clientOrderId, + ) + ) { + throw new Error( + 'Lighter TWAP client order ID is already tracked; never replay schedules', + ); + } + if (journal.records.length >= LIGHTER_NATIVE_PROBE_RECORD_LIMIT) { + throw new Error('Lighter TWAP journal capacity exceeded'); + } + if ( + journal.records.some( + (record) => + !(this.#terminalMappingVerified && record.terminalConfirmed) && + record.placement.phase !== 'failed', + ) + ) { + throw new Error( + 'An earlier Lighter TWAP schedule remains unresolved for this account', + ); + } + const record: LighterTwapRecord = { + intent: frozenIntent, + placement: { phase: 'prepared' }, + cancellations: [], + terminalConfirmed: false, + }; + journal.records.push(record); + await this.#write(frozenIntent.owner, journal); + await this.#dispatch(frozenIntent.owner, journal, record.placement, send); + return record; + }); + } + + /** + * @param owner - Current account scope; original signing slot stays recorded. + * @param clientOrderId - Exact persisted schedule identity. + * @param send - Exact-parent cancellation transport under the venue lock. + * @returns Cancel-requested state; acknowledgment is never terminal proof. + */ + async cancel( + owner: LighterTwapOwner, + clientOrderId: string, + send: Send, + ): Promise { + return await this.#locked(owner, async () => { + const journal = await this.#read(owner); + const record = journal.records.find( + (entry) => entry.intent.clientOrderId === clientOrderId, + ); + if (!record) { + throw new Error('Unknown owned Lighter TWAP schedule'); + } + if (this.#terminalMappingVerified && record.terminalConfirmed) { + return record; + } + const latest = record.cancellations.at(-1); + if (latest && latest.phase !== 'failed') { + throw new Error('Lighter TWAP cancellation remains unresolved'); + } + if (record.cancellations.length >= LIGHTER_NATIVE_PROBE_CANCEL_LIMIT) { + throw new Error('Lighter TWAP cancel attempt limit reached'); + } + const dispatch: LighterTwapDispatch = { phase: 'prepared' }; + record.cancellations.push(dispatch); + await this.#write(owner, journal); + await this.#dispatch(owner, journal, dispatch, send); + return record; + }); + } + /** + * Collect read-only exact-parent, child and transaction evidence. This never + * signs, submits, replays or treats absent orders as terminated schedules. + * + * @param owner - Current account ownership, fenced after every network await. + * @param client - Validating venue read client. + * @param token - Read token owned by this account, never persisted or returned. + * @returns Complete observations or explicit per-record mapping uncertainty. + */ + async observe( + owner: LighterTwapOwner, + client: ReadClient, + token: string, + ): Promise { + return await this.#locked(owner, async () => { + const journal = await this.#read(owner); + const results: LighterTwapReadObservation[] = []; + for (const record of journal.records) { + const result: LighterTwapReadObservation = { + record, + orders: [], + trades: [], + }; + results.push(result); + const previousPlacement = { ...record.placement }; + try { + const account = record.intent.owner.accountIndex; + for (const dispatch of [record.placement, ...record.cancellations]) { + if (dispatch.phase === 'prepared' || dispatch.phase === 'signed') { + // Transport can start only after attempted is persisted. + dispatch.phase = 'failed'; + } + if ( + dispatch.txHash === undefined || + dispatch.phase === 'failed' || + dispatch.phase === 'succeeded' + ) { + continue; + } + const transaction = await client.getTx(dispatch.txHash); + this.#assertCurrent(owner); + if (transaction === null) { + if ( + dispatch.expiresAt !== undefined && + Date.now() > dispatch.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS + ) { + dispatch.phase = 'failed'; + } + continue; + } + if ( + transaction.hash.toLowerCase().replace(/^0x/u, '') !== + dispatch.txHash.toLowerCase().replace(/^0x/u, '') || + transaction.accountIndex !== account || + transaction.apiKeyIndex !== record.intent.owner.apiKeyIndex || + transaction.nonce !== dispatch.nonce + ) { + throw new Error('Lighter TWAP transaction identity mismatch'); + } + const outcome = getLighterTransactionOutcome(transaction.status); + if (outcome === 'executed') { + dispatch.phase = 'succeeded'; + } else if (outcome === 'failed') { + dispatch.phase = 'failed'; + } + } + const exact = await client.getOrdersByClientIds(account, token, [ + record.intent.clientOrderId, + ]); + this.#assertCurrent(owner); + const active = await client.getActiveOrders( + account, + token, + record.intent.marketId, + ); + this.#assertCurrent(owner); + const history: LighterApiOrder[] = []; + let cursor: string | undefined; + const cursors = new Set(); + let complete = false; + for ( + let page = 0; + page < LIGHTER_NATIVE_PROBE_PAGE_LIMIT; + page += 1 + ) { + const response = await client.getInactiveOrders( + account, + token, + LIGHTER_NATIVE_PROBE_PAGE_SIZE, + cursor, + record.intent.marketId, + ); + this.#assertCurrent(owner); + history.push(...response.orders); + if (!response.nextCursor) { + complete = true; + break; + } + if ( + cursors.has(response.nextCursor) || + response.orders.length === 0 + ) { + throw new Error( + 'Lighter TWAP order history pagination is incomplete', + ); + } + cursors.add(response.nextCursor); + cursor = response.nextCursor; + } + if (!complete) { + throw new Error( + 'Lighter TWAP order history pagination limit reached', + ); + } + const allRows = [...exact.orders, ...active.orders, ...history]; + const parentCandidates = allRows.filter((row) => + matchesClientId(row, record.intent.clientOrderId), + ); + const parentIds = new Set( + parentCandidates.map( + (row) => row.orderId ?? String(row.orderIndex), + ), + ); + const byId = new Map(); + for (const row of allRows.filter( + (candidate) => + parentCandidates.includes(candidate) || + parentIds.has(candidate.parentOrderId ?? '') || + parentIds.has(String(candidate.parentOrderIndex)), + )) { + const id = row.orderId ?? String(row.orderIndex); + const previous = byId.get(id); + if (previous && JSON.stringify(previous) !== JSON.stringify(row)) { + throw new Error( + 'Lighter TWAP order changed during snapshot collection', + ); + } + byId.set(id, row); + } + result.orders = [...byId.values()]; + const parents = result.orders.filter((row) => + matchesClientId(row, record.intent.clientOrderId), + ); + if (parents.length === 0 && record.placement.phase === 'failed') { + result.issue = + 'Native TWAP placement definitively failed; no matching parent observed'; + continue; + } + if (parents.length !== 1) { + throw new Error( + 'Lighter TWAP parent unavailable within queried history; retain obligation', + ); + } + const parent = parents[0]; + const parentId = parent.orderId ?? String(parent.orderIndex); + if (record.placement.phase === 'failed') { + throw new Error( + 'Lighter TWAP failed transaction conflicts with a visible parent', + ); + } + const children = result.orders.filter( + (row) => + row.parentOrderId === parentId || + row.parentOrderIndex === parent.orderIndex, + ); + for (const child of children) { + const orderIndex = child.orderId ?? String(child.orderIndex); + let tradeCursor: string | undefined; + const tradeCursors = new Set(); + let tradesComplete = false; + for ( + let page = 0; + page < LIGHTER_NATIVE_PROBE_PAGE_LIMIT; + page += 1 + ) { + const response = await client.getTrades(account, token, { + marketId: record.intent.marketId, + orderIndex, + aggregate: false, + limit: LIGHTER_NATIVE_PROBE_PAGE_SIZE, + cursor: tradeCursor, + }); + this.#assertCurrent(owner); + result.trades.push(...response.trades); + if (!response.nextCursor) { + tradesComplete = true; + break; + } + if ( + tradeCursors.has(response.nextCursor) || + response.trades.length === 0 + ) { + throw new Error('Lighter TWAP trade pagination is incomplete'); + } + tradeCursors.add(response.nextCursor); + tradeCursor = response.nextCursor; + } + if (!tradesComplete) { + throw new Error('Lighter TWAP trade pagination limit reached'); + } + } + const finalParent = await client.getOrdersByClientIds( + account, + token, + [record.intent.clientOrderId], + ); + this.#assertCurrent(owner); + if ( + finalParent.orders.length !== 1 || + JSON.stringify(finalParent.orders[0]) !== JSON.stringify(parent) + ) { + throw new Error( + 'Lighter TWAP parent changed during fill collection', + ); + } + result.observation = reconcileLighterTwapObservation( + record, + result.orders, + result.trades, + Date.now(), + ); + record.parentOrderId = result.observation.parentOrderId; + if ( + this.#terminalMappingVerified && + result.observation.terminalObserved && + record.placement.phase === 'succeeded' + ) { + const latestCancel = record.cancellations.at(-1); + const cancellationSettled = + parent.status !== 'canceled' || + latestCancel?.phase === 'succeeded'; + const expirySettled = + parent.status !== 'canceled-expired' || + Date.now() >= record.intent.orderExpiry; + if (cancellationSettled && expirySettled) { + record.terminalEvidence = { + parentOrderId: parentId, + status: parent.status, + observedAt: Date.now(), + }; + record.terminalConfirmed = true; + } + } + // Without the verified mapping gate, retain the cleanup obligation + // even when the native parent currently appears terminal. + } catch (error) { + this.#assertCurrent(owner); + // An inconsistent/incomplete snapshot must not release an obligation + // based on only the earlier transaction half of the observation. + // Required order reads did not complete consistently. Transaction + // failure alone cannot release placement while a parent may exist. + // Cancellation outcomes remain independent and are preserved. + record.placement = previousPlacement; + result.issue = error instanceof Error ? error.message : String(error); + } + } + await this.#write(owner, journal); + return results; + }); + } +} diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 6a0c587e132..379f3904f13 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -35,6 +35,7 @@ import type { PerpsFeeResolution, } from '../types/index.js'; import { ensureError } from '../utils/errorUtils.js'; +import { captureScaleOrderParams } from '../utils/lighterScaleOrders.js'; import { isLimitExecutionOrderType, SCALE_ORDER_COUNT, @@ -220,8 +221,15 @@ export class TradingService { result?.success === true ? PERPS_EVENT_VALUE.STATUS.EXECUTED : PERPS_EVENT_VALUE.STATUS.FAILED; + // A known zero fill can still represent accepted resting exposure. + // Keep execution quantities intact on the receipt and partial-fill event. + const trackedFillSize = + result?.filledSize !== undefined && + new BigNumber(result.filledSize).isZero() + ? (result.acceptedSize ?? result.filledSize) + : result?.filledSize; const trackedOrderSize = parseFloat( - result?.filledSize ?? + trackedFillSize ?? result?.acceptedSize ?? result?.submittedSize ?? params.size, @@ -563,7 +571,22 @@ export class TradingService { tpPrice?: number; }) => Promise<{ success: boolean; error?: string }>; }): Promise { - const { provider, params, context, reportOrderToDataLake } = options; + const { provider, context, reportOrderToDataLake } = options; + let params = { ...options.params }; + try { + params = captureScaleOrderParams(params); + } catch (error) { + if ( + error instanceof Error && + error.message === PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE + ) { + return { + success: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE, + }; + } + throw error; + } const traceId = uuidv4(); const startTime = this.#deps.performance.now(); let traceData: diff --git a/packages/perps-controller/src/services/lighterDispatchIdentity.ts b/packages/perps-controller/src/services/lighterDispatchIdentity.ts new file mode 100644 index 00000000000..8ebb847e7ea --- /dev/null +++ b/packages/perps-controller/src/services/lighterDispatchIdentity.ts @@ -0,0 +1,50 @@ +/** + * @param value - Untrusted signed or persisted hash. + * @returns Whether the exact native transaction hash is usable. + */ +export function isLighterTxHash(value: unknown): value is string { + return typeof value === 'string' && /^(0x)?[0-9a-fA-F]{8,128}$/u.test(value); +} + +/** + * @param value - Untrusted millisecond expiry. + * @returns Whether the signed expiry is a positive exact integer. + */ +export function isLighterTxExpiry(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +/** + * 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). + */ +export const requireSignedTxIdentity = (signed: { + txHash?: string; + txInfo?: string; +}): { txHash: string; expiresAt: number } => { + const { txHash } = signed; + if (!isLighterTxHash(txHash)) { + throw new Error( + 'Lighter signing result carries no usable txHash; refusing to submit an unreconcilable mutation', + ); + } + let expiresAt: unknown; + try { + expiresAt = (JSON.parse(signed.txInfo ?? '') as Record) + .ExpiredAt; + } catch { + expiresAt = undefined; + } + if (!isLighterTxExpiry(expiresAt)) { + throw new Error( + 'Lighter signing result carries no usable ExpiredAt; refusing to submit an unreconcilable mutation', + ); + } + return { txHash, expiresAt }; +}; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 1ea285dfc2a..476025f6c43 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -239,6 +239,17 @@ export type TPSLTrackingData = { /** Collateral mode requested for a new order. */ export type MarginMode = 'isolated' | 'cross'; +/** Exact approved Lighter Scale preview. Other providers reject this constraint. */ +export type ExpectedScaleLadder = Readonly<{ + prices: readonly string[]; + sizes: readonly string[]; + totalSize: string; + totalNotional: string; + minimumBaseSize: string; + minimumQuoteAmount: string; + sizeDecimals: number; +}>; + // MetaMask Perps API order parameters for PerpsController export type OrderParams = { symbol: string; // Asset identifier (e.g., 'ETH', 'BTC', 'xyz:TSLA') @@ -284,6 +295,12 @@ export type OrderParams = { * `splitScaleSizes` for how the sizes are allocated. */ scaleSkew?: number; + /** + * Bind Lighter placement to the displayed normalized ladder and minimums. + * Omission retains legacy placement-time normalization with no displayed-preview guarantee. + * This is intent evidence, not accepted-order or fill evidence. + */ + expectedScaleLadder?: ExpectedScaleLadder; chaseIntervalMs?: number; // How often the chase re-reads the touch (default 15000, min 1000) chaseMaxDurationMs?: number; // Optional hard stop for the chase window (unbounded by default) chaseMaxRepricings?: number; // Optional cap on cancel/replace cycles (unbounded by default) @@ -312,7 +329,9 @@ export type OrderParams = { currentPrice?: number; // Current market price (avoids extra API call if provided) leverage?: number; // Leverage to apply for the order (e.g., 10 for 10x leverage) /** - * Explicit collateral mode. Requires leverage. HyperLiquid validates market + * Explicit collateral mode. Requires leverage. Lighter native orders verify + * mode execution and visibility before exposure and refuse changes with an + * open position or resting/pending orders. HyperLiquid validates market * support and refuses mode changes with an open position or resting order. * Omit to let the provider choose: HyperLiquid keeps an open position's * mode and otherwise uses isolated. @@ -329,7 +348,7 @@ export type OrderParams = { export type ScaleOrderChild = | { - state: 'resting' | 'filled'; + state: 'resting' | 'filled' | 'canceled'; orderId: string; } | { @@ -337,7 +356,101 @@ export type ScaleOrderChild = orderId?: never; }; +/** Durable identities for one native parent plus attached protection group. */ +export type AttachedOrderGroup = { + groupId: string; + providerId: PerpsProviderType; + symbol: string; + /** Acceptance never proves activation or the quantity protected after a partial fill. */ + submission: 'prepared' | 'unknown' | 'accepted' | 'canceled' | 'completed'; + parentClientOrderId: string; + childClientOrderIds: string[]; + /** Exact venue IDs, when observed. Unknown identities remain absent. */ + parentOrderId?: string; + childOrderIds?: string[]; + /** Automatic parent/child cancellation is not assumed. */ + cancellation: 'explicit-exact-owned-orders'; + /** Explicit review observations; terminal groups without a pending dispatch return local identities only. Absence is not an empty book. */ + orders?: { + clientOrderId: string; + orderId?: string; + status: + | 'unknown' + | 'waiting' + | 'resting' + | 'partially-filled' + | 'filled' + | 'canceled' + | 'rejected'; + filledSize?: string; + }[]; + /** A bounded review leaves missing legs unknown rather than asserting absence. */ + historyStatus?: 'complete' | 'bounded'; + /** Parent/child references only; this does not prove automatic OCO cancellation. */ + linkage?: 'confirmed' | 'unknown'; +}; + +/** Durable strategy inventory without fabricated venue IDs or unknown fills. */ +export type ScaleOrderGroup = Pick< + OrderResult, + | 'orderId' + | 'submittedSize' + | 'acceptedSize' + | 'acceptedChildren' + | 'childOrderIds' + | 'weightedAverageLimitPrice' + | 'filledSize' +> & { + groupId: string; + symbol: string; + providerId: PerpsProviderType; + accountIndex: number; + apiKeyIndex: number; + state: 'placing' | 'stopped' | 'unknown' | 'terminal'; + walletAddress: string; + network: 'mainnet' | 'testnet'; +}; + +/** Same-order edit outcome; acceptance alone remains pending. */ +export type OrderEditObservation = { + status: 'pending' | 'settled' | 'failed' | 'terminal'; + requestedPrice: string; + requestedSize: string; + observation?: { + price: string; + size: string; + remainingSize: string; + filledSize: string; + status: Order['status']; + }; +}; + +/** Observed protection outcome; independent legs have no sibling cancellation guarantee. */ +export type PositionProtectionReceipt = { + linkage: 'single' | 'equal-quantity-oco' | 'independent'; + legs: { + role: 'take-profit' | 'stop-loss'; + /** Absent when the caller requested the captured full-position quantity. */ + requestedSize?: string; + normalizedSize: string; + clientOrderId?: string; + orderId?: string; + status: + | 'unknown' + | 'resting' + | 'partially-filled' + | 'filled' + | 'canceled' + | 'rejected'; + }[]; +}; + export type OrderResult = { + /** Exact protection intent and any observed leg outcomes, including failures. */ + positionProtection?: PositionProtectionReceipt; + /** Durable native edit outcome, including unresolved earlier intent on retry. */ + orderEdit?: OrderEditObservation; + attachedOrderGroup?: AttachedOrderGroup; success?: boolean; /** * What names the placement afterwards. @@ -345,11 +458,13 @@ export type OrderResult = { * For an ordinary placement this is the exchange's order ID. For a *strategy* * placement it is a handle instead — a venue TWAP id, or a client-generated * scale-group or chase-session id — which is what `CancelOrderParams` takes - * together with the matching `orderType` and `providerId`. Scale and chase - * handles are held in the provider session that created them. Scale handles - * are also encoded in each rung's venue client-order ID, so an open-order - * read can recover the group after reconnect. Chase handles cannot be + * together with the matching `orderType` and `providerId`. Hyperliquid Scale handles + * are encoded in venue client-order IDs and recovered from open-order reads. + * Lighter Scale handles and unresolved rungs persist in account-scoped local + * storage. Hyperliquid Chase handles are session-local and cannot be * recovered because every replacement receives a new exchange order ID. + * Lighter retains local durable Chase handles for explicit cleanup but + * never automatically resumes a loop after reconnect. * The individual exchange IDs a strategy expanded into are in * `childOrderIds`. */ @@ -389,6 +504,12 @@ export type OrderResult = { // When TP/SL protection cannot be fully restored, these identify the old // orders that survived, may still be live when reconciliation failed, or were // recreated; an empty array means none are known or potentially live. + // Successful position TP/SL updates may return an exact receipt: newly + // created venue IDs in request order (TP then SL), including children that + // already executed or were cancelled by OCO. A successful removal returns + // []. Absence means exact receipt identity is unavailable, not zero children. + // These IDs establish ownership, not current resting status; re-read before + // cancellation. They exclude the old protection cancelled by replacement. childOrderIds?: string[]; providerId?: PerpsProviderType; // Multi-provider: which provider executed this order (injected by aggregator) /** @@ -409,6 +530,13 @@ export type OrderResult = { * user. Surfaced by providers with durable settlement state (Lighter). */ export type PerpsPendingManualRecovery = { + /** Owning provider route; mandatory on aggregated recovery rows. */ + providerId?: PerpsProviderType; + /** Issuing wallet/network scope when supplied by the provider. */ + walletAddress?: string; + network?: string; + /** Opaque selected source-operation identity, when explicit resolution is supported. */ + recoveryId?: string; symbol: string; /** Durable identity (address:accountIndex:apiKey:symbol). */ settlementKey: string; @@ -419,19 +547,79 @@ export type PerpsPendingManualRecovery = { priorIntent: 'replace' | 'remove'; /** Venue order ids still on the books when the state was parked. */ survivingOrderIds: string[]; + /** + * Original fixed coverage, for review only; never permission to replay. + * A current-key journal may still belong to a running operation. Await its + * result before selecting explicit recovery; listing does not reconcile it. + */ + partialIntent?: { + version: 1 | 2; + positionSide: 'long' | 'short'; + linkage: 'single' | 'oco' | 'independent'; + legs: { + type: 'take-profit' | 'stop-loss'; + size: string; + clientOrderId: string; + }[]; + }; /** What the user should do to resolve the state. */ actionNeeded: string; }; +/** Fresh, authoritative venue state for one issuing provider context. */ +export type PerpsRecoveryVenueReview = + | { status: 'unsupported'; providerId: PerpsProviderType; reason: string } + | { + status: 'ready'; + providerId: PerpsProviderType; + walletAddress: string; + network: string; + accountIndex: number; + positions: Position[]; + orders: Order[]; + reviewedAt: number; + }; + +/** Explicit successor protection for one opaque durable source obligation. */ +export type ResolveRecoveryProtectionParams = UpdatePositionTPSLParams & { + recoveryId: string; +}; + +/** Unsupported capability is distinct from a supported but unresolved intent. */ +export type PerpsRecoveryProtectionResult = + | (OrderResult & { + status: 'settled' | 'unresolved'; + providerId: PerpsProviderType; + }) + | { + status: 'unsupported'; + providerId: PerpsProviderType; + success: false; + error: string; + }; + /** - * A previously ambiguous dispatch whose outcome was later resolved. - * Writes stay blocked until each outcome is explicitly acknowledged via + * A local dispatch requiring reconciliation or acknowledgment. Raw unresolved + * dispatches include current-session in-flight submissions, have + * acknowledgeable:false and cannot be acknowledged. Listing starts no + * background reconciliation. Explicit reconcileRecoveredDispatches checks venue + * evidence without signing or retrying; TP/SL-owned entries remain pending. A + * later fenced financial action still blocks unresolved dispatches. For + * resolved outcomes, writes stay blocked until 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. */ + /** Owning provider for strict review and explicit acknowledgment routing. */ + providerId?: PerpsProviderType; + walletAddress?: string; + network?: string; + /** Opaque stable id for selective acknowledgment; preserve exactly, never parse. */ recoveryId: string; + /** Original trading-key slot when the provider has per-key recovery ledgers. */ + apiKeyIndex?: number; + /** False for unresolved raw dispatches: acknowledgment is forbidden until reconciled. */ + acknowledgeable?: boolean; /** Venue transaction type of the dispatch. */ kind: number; /** Human-readable operation intent. */ @@ -519,6 +707,110 @@ export type ChaseOrder = { providerId?: PerpsProviderType; }; +/** Original durable Chase owner. Observation never grants current cleanup authority. */ +export type PerpsChaseOrderOwner = { + providerId: PerpsProviderType; + walletAddress: string; + network: 'testnet' | 'mainnet'; + accountIndex: number; + apiKeyIndex: number; +}; + +/** Public transaction identity and durable dispatch phase, without signed payloads. */ +export type PerpsChaseOrderDispatch = { + phase: 'prepared' | 'signed' | 'attempted' | 'acknowledged' | 'failed'; + nonce?: number; + txHash?: string; + expiresAt?: number; +}; + +/** One durable child, including superseded, terminal and unresolved attempts. */ +export type PerpsChaseOrderChild = { + /** Exact opaque native client ID. Never infer it from the stable handle. */ + clientOrderId: string; + size: string; + price: string; + quotedAt: number; + placement: PerpsChaseOrderDispatch; + cancellations: PerpsChaseOrderDispatch[]; + /** + * Last exact persisted venue observation, not a fresh venue read. Absence + * means no observation; an attempted or acknowledged dispatch stays unknown. + */ + observation?: { + orderId: string; + terminal: boolean; + filledSize: string; + filledNotional: string; + remainingSize: string; + }; +}; + +/** Select one opaque handle and explicit provider, optionally fencing its original owner. */ +export type GetChaseOrderOwnershipParams = { + handle: string; + providerId: PerpsProviderType; + owner?: PerpsChaseOrderOwner; +}; + +/** Reconcile only the exact already-attempted cancellation under its original owner. */ +export type ReconcileChaseOrderCancellationParams = { + handle: string; + providerId: PerpsProviderType; + owner: PerpsChaseOrderOwner; + clientOrderId: string; + cancellation: Pick< + Required, + 'nonce' | 'txHash' | 'expiresAt' + >; +}; + +/** No result authorizes a new cancellation; uncertain or lost replies stay unresolved. */ +export type ReconcileChaseOrderCancellationResult = + | { + status: 'unsupported'; + providerId: PerpsProviderType; + handle: string; + reason: string; + } + | { + status: 'settled' | 'unresolved'; + providerId: PerpsProviderType; + handle: string; + order: ChaseOrder; + }; + +/** + * Validated durable local history for one exact handle. Available means every + * locally recorded child is included, not that venue reconciliation is complete. + * Missing records never report an available empty inventory; corrupt storage rejects. + */ +export type PerpsChaseOrderOwnership = + | { + status: 'unsupported'; + providerId: PerpsProviderType; + handle: string; + reason: string; + } + | { + status: 'unavailable'; + providerId: PerpsProviderType; + handle: string; + reason: 'not_found' | 'owner_mismatch'; + } + | { + status: 'available'; + providerId: PerpsProviderType; + handle: string; + owner: PerpsChaseOrderOwner; + order: ChaseOrder; + children: PerpsChaseOrderChild[]; + executedSize: string; + executedNotional: string; + lastTickAt: number; + stopReason?: ChaseOrderStatus; + }; + /** Lifecycle signal emitted when a Chase reaches its configured distance. */ export type ChaseOrderMaxDistanceReached = { handle: string; @@ -751,6 +1043,8 @@ export type DisconnectResult = { export type MarketInfo = { name: string; // HyperLiquid: universe name (asset symbol) szDecimals: number; // HyperLiquid: size decimals + /** Fixed decimal price grid, when the venue declares one. */ + priceDecimals?: number; maxLeverage: number; // HyperLiquid: max leverage marginTableId: number; // HyperLiquid: margin requirements table ID marginMode?: 'strictIsolated' | 'noCross'; // HyperLiquid market capability @@ -936,8 +1230,9 @@ export type CancelOrderResult = { * What was cancelled, named the same way it was placed: an exchange order ID * for an ordinary cancel, and the strategy handle — TWAP id, scale group, or * chase session — when `CancelOrderParams.orderType` named one. Scale and - * chase handles can only be cancelled during the provider session that - * created them; TWAP IDs are venue-owned. + * Hyperliquid chase handles can only be cancelled during the provider session + * that created them. Lighter durable chase handles remain available for + * explicit owned cleanup after reconnect; TWAP IDs are venue-owned. */ orderId?: string; error?: string; @@ -1149,12 +1444,17 @@ export type HyperLiquidCredentials = { }; export type LighterCredentials = { + /** Explicit bounded Chase probe on the actual testnet provider; ignored on mainnet. */ + chaseTestnetProbe?: boolean; /** 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). */ + /** + * Preferred trading-key slot (defaults to LIGHTER_DEFAULT_API_KEY_INDEX). + * Discovery-capable bridges may select another matching or unused slot. + */ apiKeyIndex?: number; /** * Client-owned Lighter signer. The client creates and persists the venue @@ -1400,13 +1700,29 @@ export type SubscribePositionsParams = { }; export type SubscribeOrderFillsParams = { - callback: (fills: OrderFill[], isSnapshot?: boolean) => void; + /** + * Reports detectable setup failures without asserting an empty account. + * Lighter currently produces these errors; other providers may not report + * setup failures through this optional callback. + */ + onError?: (error: Error, sourceProviderId?: PerpsProviderType) => void; + callback: ( + fills: OrderFill[], + isSnapshot?: boolean, + sourceProviderId?: PerpsProviderType, + ) => void; accountId?: CaipAccountId; // Optional: defaults to selected account since?: number; // Future: only fills after timestamp }; export type SubscribeOrdersParams = { - callback: (orders: Order[]) => void; + /** + * Reports detectable setup failures without asserting an empty account. + * Lighter currently produces these errors; other providers may not report + * setup failures through this optional callback. + */ + onError?: (error: Error, sourceProviderId?: PerpsProviderType) => void; + callback: (orders: Order[], sourceProviderId?: PerpsProviderType) => void; accountId?: CaipAccountId; // Optional: defaults to selected account includeHistory?: boolean; // Optional: include filled/canceled orders }; @@ -1702,13 +2018,26 @@ export type GetScalePriceLadderParams = { count: number; /** Optional explicit route; omitted uses the active/default provider. */ providerId?: PerpsProviderType; + /** + * Optional exact base size or maximum quote budget, with a rung-size skew. + * Providers without sizing support leave `sizingPreview` absent. + */ + sizing?: Readonly< + ( + | { size: string; usdAmount?: never } + | { usdAmount: string; size?: never } + ) & { + skew?: number; + } + >; }; -/** Provider-owned strategy capabilities for the selected market route. */ +/** Reasons provider-owned order capabilities are unavailable for a market. */ export type DirectProviderOrderCapabilitiesUnavailableReason = | 'provider_unavailable' | 'invalid_symbol' | 'market_not_found' + | 'order_market_unsupported' | 'strategy_market_unsupported'; export type RoutedOrderCapabilitiesUnavailableReason = @@ -1724,12 +2053,52 @@ type ReadyPerpsOrderCapabilities = Readonly<{ status: 'ready'; providerId: PerpsProviderType; supportedStrategies: readonly StrategyOrderType[]; + /** + * Standalone stop/take-profit placements this market's provider executes. + * Omitted means support is not reported; clients must not infer support + * from the provider name. Attached or position TP/SL is a separate contract. + */ + supportedTriggerOrderTypes?: readonly TriggerOrderType[]; + /** + * Position protection mutation guarantees. Omitted means unsupported or + * unreported. Accepted updates can still omit IDs when exact correlation + * remains unresolved; clients must not infer identities from book changes. + */ + positionTpsl?: Readonly<{ + supportsExpectedPosition: true; + childOrderIds: 'request-correlated'; + takeProfitOrderType: 'take_profit_market' | 'take_profit_limit'; + stopLossOrderType: 'stop_market' | 'stop_limit'; + /** Coverage when the caller omits explicit TP/SL sizes. */ + defaultCoverage?: 'position-snapshot' | 'dynamic-position'; + /** Fixed positive quantities; omitted means support is unreported. */ + partialCoverage?: Readonly<{ + /** When reported, a single explicitly sized leg is supported. */ + single: true; + /** Equal native OCO quantities, or independently sized sibling triggers. */ + pair: 'equal-quantity-oco' | 'independent'; + /** Explicit alternatives; absent permits only the declared default pair. */ + supportedPairs?: readonly ('equal-quantity-oco' | 'independent')[]; + /** Prior selected protection is canceled before replacement creation. */ + replacement: 'cancel-before-create'; + /** Explicit recovery ID/current intent, or the provider's existing recovery policy. */ + recovery: 'explicit-current-position-intent' | 'provider-default'; + }>; + }>; + /** * Margin modes the market accepts for `OrderParams.marginMode`. Omitted * means the provider does not report it, and clients should not offer an * explicit margin mode. */ supportedMarginModes?: readonly MarginMode[]; + attachedTpsl?: Readonly<{ + submission: 'native-oto-otoco'; + childCoverage: 'venue-native-zero-size'; + partialSizes: false; + lifecycleVerification: 'pending'; + cancellation: 'explicit-exact-owned-orders'; + }>; }>; export type DirectProviderOrderCapabilities = @@ -1751,7 +2120,7 @@ export type PerpsOrderCapabilities = /** Reasons a direct provider cannot produce a Scale price ladder. */ export type DirectProviderScalePriceLadderUnavailableReason = Exclude< DirectProviderOrderCapabilitiesUnavailableReason, - 'strategy_market_unsupported' + 'strategy_market_unsupported' | 'order_market_unsupported' >; /** Reasons a provider-routed Scale price ladder cannot be produced. */ @@ -1765,6 +2134,18 @@ export type PerpsScalePriceLadder = status: 'ready'; providerId: PerpsProviderType; prices: readonly string[]; + /** + * Exact venue-valid rung quantities when sizing is supported and requested. + * This previews maker minimums and exposure, not account trading readiness. + */ + sizingPreview?: Readonly<{ + sizes: readonly string[]; + totalSize: string; + totalNotional: string; + minimumBaseSize: string; + minimumQuoteAmount: string; + sizeDecimals: number; + }>; }> | Readonly<{ status: 'unavailable'; @@ -1946,6 +2327,12 @@ export type PerpsFeeResolution = { }; export type UpdatePositionTPSLParams = { + /** + * Optional mutation precondition, checked against authoritative position data + * before setup and again before protection writes. Size is signed base units. + * A mismatch rejects the update rather than protecting a different position. + */ + expectedPosition?: { size: string; entryPrice: string }; symbol: string; // Asset identifier (e.g., 'BTC', 'ETH', 'xyz:TSLA') takeProfitPrice?: string; // Optional: undefined to remove stopLossPrice?: string; // Optional: undefined to remove @@ -1955,6 +2342,8 @@ export type UpdatePositionTPSLParams = { // quantity. takeProfitSize?: string; stopLossSize?: string; + /** Pair-only opt-in for partial position coverage; omission keeps the provider default. */ + partialPairLinkage?: 'equal-quantity-oco' | 'independent'; // Optional tracking data for MetaMetrics events trackingData?: TPSLTrackingData; providerId?: PerpsProviderType; // Multi-provider: optional provider override for routing @@ -2011,13 +2400,20 @@ export type Funding = { }; export type PerpsProvider = { + /** Local durable Scale inventory; unsupported providers omit the methods. */ + getScaleOrderGroups?(): Promise; + /** Explicit read-only venue reconciliation, without placement replay. */ + reviewScaleOrderGroups?(params?: { + providerId?: PerpsProviderType; + }): Promise; + readonly protocolId: string; /** Whether this provider routes individual requests by `providerId`. */ readonly routesOrdersByProviderId?: boolean; /** - * Return strategy capabilities for the provider/market route. Providers may + * Return order capabilities for the provider/market route. Providers may * omit this hook; the controller then reports capabilities as unavailable. */ getOrderCapabilities?( @@ -2039,9 +2435,9 @@ export type PerpsProvider = { /** * Normalize a Scale ladder using the selected provider's venue rules. * - * @param params - Market, ladder bounds, count, and optional provider route. - * @returns Provider-normalized prices or a typed unavailable result. - * @throws When the provider cannot normalize the requested ladder. + * @param params - Market, ladder bounds, count, optional sizing and provider route. + * @returns Provider-normalized prices with sizingPreview when supported, or a typed unavailable result. + * @throws When bounds or sizing violate the provider's venue rules. */ getScalePriceLadder?( params: GetScalePriceLadderParams, @@ -2063,6 +2459,14 @@ export type PerpsProvider = { */ subscribeToTwapOrders?(params: SubscribeTwapOrdersParams): () => void; getChaseOrders?(): Promise; + /** Pure local observation; no signing, transport, continuation or durable writes. */ + getChaseOrderOwnership?( + params: GetChaseOrderOwnershipParams, + ): Promise; + /** Observe an exact retained cancellation without signing or dispatching a successor. */ + reconcileChaseOrderCancellation?( + params: ReconcileChaseOrderCancellationParams, + ): Promise; suspendChaseOrders?(): Promise; closePosition(params: ClosePositionParams): Promise; closePositions?(params: ClosePositionsParams): Promise; // Optional: batch close for protocols that support it @@ -2072,7 +2476,19 @@ export type PerpsProvider = { // settlement state — Lighter). Read-only listings plus selective, // explicit acknowledgment; never destructive read-all. getPendingManualRecoveries?(): Promise; + reviewRecoveryVenue?(params?: { + providerId?: PerpsProviderType; + }): Promise; + resolveRecoveryProtection?( + params: ResolveRecoveryProtectionParams, + ): Promise; getRecoveredDispatches?(): Promise; + /** Read local native attached identities; never creates, reattaches or replays orders. */ + getAttachedOrderGroups?(): Promise; + /** Explicit venue review with existing registered local read authority only. */ + reviewAttachedOrderGroups?(): Promise; + /** Non-financial venue checks with local persistence; no signing, retry or acknowledgment. */ + reconcileRecoveredDispatches?(): Promise; acknowledgeRecoveredDispatch?(recoveryId: string): Promise; getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index 9f85def2997..4c04cb092b4 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -81,11 +81,32 @@ export type LighterSignCreateOrderWireParams = [ nonce: number, ]; -export type LighterSignCreateGroupedOrdersWireParams = [ +/** Native OTO/OCO use two orders; OTOCO uses a parent and two children. */ +export type LighterSignCreateGroupedOrdersWireParams = + | [ + accountIndex: number, + groupingType: 1 | 2, + orderCount: 2, + ...orders: LighterGroupedOrderWireParams, + nonce: number, + ] + | [ + accountIndex: number, + groupingType: 3, + orderCount: 3, + ...parentAndFirstChild: LighterGroupedOrderWireParams, + ...secondChild: LighterCreateOrderWireParams, + nonce: number, + ]; + +/** Seven-argument ModifyOrder ABI of the pinned embedded signer. */ +export type LighterSignModifyOrderWireParams = [ accountIndex: number, - groupingType: number, - orderCount: 2, - ...orders: LighterGroupedOrderWireParams, + marketIndex: number, + orderIndex: string, + baseAmount: number, + price: number, + triggerPrice: number, nonce: number, ]; @@ -150,6 +171,10 @@ export type LighterSignerOperationMap = { LighterSignCreateGroupedOrdersWireParams, LighterTxResult >; + _signModifyOrder: LighterSignerOperationDefinition< + LighterSignModifyOrderWireParams, + LighterTxResult + >; _signCancelOrder: LighterSignerOperationDefinition< LighterSignCancelOrderWireParams, LighterTxResult @@ -192,6 +217,8 @@ export type LighterCreateClientParams = { accountIndex: number; apiKeyIndex: number; nonce: number; + /** Expected Core wallet binding. Hosts must reject a different selected account. */ + walletAddress?: string; }; /** @@ -203,6 +230,32 @@ export type LighterCreateClientParams = { * - Node (e2e): in-process `WebAssembly.instantiate` via Go's `wasm_exec.js`. */ export type LighterSignerBridge = { + /** + * Return requested slots whose keys can be restored from local storage or + * wallet-owned deterministic derivation. This must never generate unrelated + * random keys for slots reported as recoverable. Core still verifies the + * derived public key against the venue before reusing any registration. + * When both discovery hooks exist, Core uses this hook exclusively; + * getStoredKeyIndices is the fallback only when this hook is absent. + */ + getRecoverableKeyIndices?(params: { + chainId: number; + accountIndex: number; + apiKeyIndices: number[]; + walletAddress?: string; + }): Promise; + /** + * Opt in to device-key recovery. Return only requested slots whose private + * keys are persisted locally for this network and account. No key material + * crosses this boundary. Core verifies public-key ownership before reuse. + */ + getStoredKeyIndices?(params: { + chainId: number; + accountIndex: number; + apiKeyIndices: number[]; + walletAddress?: string; + }): Promise; + /** * Create or restore the client-owned venue signer. The implementation owns * key generation and persistence; Core never receives the seed or private @@ -429,6 +482,11 @@ export type LighterApiPosition = { symbol: string; initialMarginFraction: string; openOrderCount: number; + /** Optional in legacy captures; live mode changes require authoritative counts. */ + pendingOrderCount?: number; + positionTiedOrderCount?: number; + /** Isolated collateral allocated to this market, in USDC. */ + allocatedMargin?: string; /** 1 = long, -1 = short (sign convention per API). */ sign: number; position: string; @@ -645,6 +703,9 @@ export type LighterWsTradesMessage = { * One trade from `GET /api/v1/trades` (post-camelization). */ export type LighterRestTrade = { + tradeIdStr?: string; + bidIdStr?: string; + askIdStr?: string; tradeId: number; txHash: string; type: string; @@ -689,6 +750,10 @@ export type LighterTradesResponse = { /** Query parameters supported by the Lighter trades endpoint. */ export type LighterTradesQuery = { + /** Exact uint60 order ID; a number would lose precision for large IDs. */ + orderIndex?: string; + /** False preserves individual trade identities for reconciliation. */ + aggregate?: boolean; limit: number; cursor?: string; from?: number; @@ -774,6 +839,13 @@ export type LighterApiOrder = { remainingBaseAmount: string; /** Executed base amount; zero remaining does not imply a fill on cancellation. */ filledBaseAmount?: string; + filledQuoteAmount?: string; + clientOrderId?: string; + /** Venue order nonce, independent of the signing API key transaction nonce. */ + nonce?: number; + createdAt?: number; + updatedAt?: number; + transactionTime?: number; price: string; isAsk: boolean; type: string; @@ -799,6 +871,21 @@ export type LighterApiOrder = { toTriggerOrderId1?: string; }; +/** Edit-only reader preserves venue int64 identities instead of rounding them. */ +export type LighterEditableOrder = Omit< + LighterApiOrder, + 'orderIndex' | 'parentOrderIndex' +> & { + orderIndex: number | string; + parentOrderIndex?: number | string; +}; + +export type LighterEditableOrdersResponse = { + code: number; + message?: string; + orders: LighterEditableOrder[]; +}; + /** * Response of `GET /api/v1/accountActiveOrders`. */ @@ -892,3 +979,23 @@ export type LighterTransferHistoryResponse = { transfers: LighterTransferHistoryItem[]; cursor?: string; }; + +/** Public native book rows; individual orders permit exact own-order subtraction. */ +export type LighterBookOrder = { + orderIndex: number; + orderId: string; + ownerAccountIndex: number; + initialBaseAmount: string; + remainingBaseAmount: string; + price: string; + orderExpiry: number; + transactionTime: number; +}; + +export type LighterOrderBookOrdersResponse = { + code: number; + totalBids: number; + totalAsks: number; + bids: LighterBookOrder[]; + asks: LighterBookOrder[]; +}; diff --git a/packages/perps-controller/src/utils/guardedHyperLiquidClient.ts b/packages/perps-controller/src/utils/guardedHyperLiquidClient.ts new file mode 100644 index 00000000000..c23aa45a3a8 --- /dev/null +++ b/packages/perps-controller/src/utils/guardedHyperLiquidClient.ts @@ -0,0 +1,44 @@ +import { ExchangeClient } from '@nktkas/hyperliquid'; + +/** + * Create an operation-local SDK client with a fence after signing and before + * transport dispatch. The shared client and transport remain unchanged. + * + * @param client - Captured SDK client, including wallet and nonce configuration. + * @param beforeDispatch - Authoritative operation precondition. + * @param onDispatchRefused - Reports the exact signed exchange payload when + * the precondition refuses dispatch. + * @returns A client whose writes are fenced at the transport boundary. + */ +export function createGuardedHyperLiquidClient( + client: ExchangeClient, + beforeDispatch: () => Promise, + onDispatchRefused?: (payload: unknown) => void, +): ExchangeClient { + const { transport } = client.config_; + return new ExchangeClient({ + ...client.config_, + transport: { + isTestnet: transport.isTestnet, + async request( + endpoint: 'info' | 'exchange', + payload: unknown, + signal?: AbortSignal, + ): Promise { + try { + await beforeDispatch(); + } catch (error) { + if (endpoint === 'exchange') { + try { + onDispatchRefused?.(payload); + } catch { + // Reporting must preserve the authoritative refusal error. + } + } + throw error; + } + return transport.request(endpoint, payload, signal); + }, + }, + }); +} diff --git a/packages/perps-controller/src/utils/lighterAdapter.ts b/packages/perps-controller/src/utils/lighterAdapter.ts index fa2475c1cb9..d520ba7b51e 100644 --- a/packages/perps-controller/src/utils/lighterAdapter.ts +++ b/packages/perps-controller/src/utils/lighterAdapter.ts @@ -105,6 +105,7 @@ export function adaptMarketFromLighter( return { name: market.symbol, szDecimals: market.supportedSizeDecimals, + priceDecimals: market.supportedPriceDecimals, maxLeverage, marginTableId: 0, // Lighter does not use margin tables minimumOrderSize: parseFloat(market.minQuoteAmount), @@ -440,13 +441,12 @@ export function adaptFillFromLighterTrade( ); } const pnl = accountIsAsk ? trade.askAccountPnl : trade.bidAccountPnl; - // Omitted amounts remain unknown. A changed sign on an existing position - // proves reduction, so its realized PnL must be supplied by the venue. + // Omitted amounts remain unknown, including closes and flips. Position + // context can establish direction without establishing the realized amount. const parsedPnl = pnl === undefined ? undefined : parseLighterStrictDecimal(pnl); if ( parsedPnl === null || - (pnl === undefined && positionBefore !== 0 && signChanged === true) || (pnl !== undefined && (typeof pnl !== 'string' || !Number.isFinite(parsedPnl))) ) { @@ -628,7 +628,7 @@ export function adaptAccountStateFromLighter( * @param status - Raw status from the Lighter API. * @returns Canonical order status. */ -function adaptOrderStatus(status: string): Order['status'] { +export function adaptOrderStatus(status: string): Order['status'] { switch (status) { case 'open': case 'pending': diff --git a/packages/perps-controller/src/utils/lighterAttachedOrders.ts b/packages/perps-controller/src/utils/lighterAttachedOrders.ts new file mode 100644 index 00000000000..7f5bd5aeccb --- /dev/null +++ b/packages/perps-controller/src/utils/lighterAttachedOrders.ts @@ -0,0 +1,315 @@ +import { + LIGHTER_ORDER_TYPE_LIMIT, + LIGHTER_ORDER_TYPE_MARKET, + LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT, + LIGHTER_ORDER_TYPE_TAKE_PROFIT, + LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT, + LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME, + LIGHTER_ORDER_EXPIRY_NONE, + LIGHTER_MAX_WIRE_PRICE, + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_TRADING_API_KEY_INDEX, +} from '../constants/lighterConfig.js'; +import type { AttachedOrderGroup } from '../types/index.js'; +import type { + LighterApiOrder, + LighterCreateOrderWireParams, +} from '../types/lighter-types.js'; + +/** Account-scoped immutable unsigned intent; no signed payloads or keys. */ +export type LighterAttachedGroup = { + version: 1; + /** Local preparation time in milliseconds; older journals may omit it. */ + preparedAt?: number; + /** Full exact venue observation persisted before nonce-ledger acceptance settlement. */ + acceptanceReviewedAt?: number; + groupId: string; + symbol: string; + accountIndex: number; + apiKeyIndex: number; + submission: AttachedOrderGroup['submission']; + orders: + | [LighterCreateOrderWireParams, LighterCreateOrderWireParams] + | [ + LighterCreateOrderWireParams, + LighterCreateOrderWireParams, + LighterCreateOrderWireParams, + ]; + txHash: string | null; + nonce: number | null; + orderExpiries?: number[]; + expiresAt: number | null; + venueIds: (string | null)[]; + /** Exact nonce-ledger settlement persisted before its evidence is retired. */ + nonAcceptance?: 'failed' | 'expired' | 'nonce-consumed'; +}; + +/** Bounded local history; unresolved groups are never evicted. */ +export const LIGHTER_ATTACHED_MAX_GROUPS = 64; +export const LIGHTER_ATTACHED_HANDLE_PREFIX = 'lighter-attached:'; + +const venueOrderTypes: Record = { + [LIGHTER_ORDER_TYPE_LIMIT]: 'limit', + [LIGHTER_ORDER_TYPE_MARKET]: 'market', + [LIGHTER_ORDER_TYPE_STOP_LOSS]: 'stop-loss', + [LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT]: 'stop-loss-limit', + [LIGHTER_ORDER_TYPE_TAKE_PROFIT]: 'take-profit', + [LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT]: 'take-profit-limit', +}; + +const isInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const isWireDecimal = (value: unknown): value is string => + typeof value === 'string' && + /^(?:0|[1-9]\d*)$/u.test(value) && + Number.isSafeInteger(Number(value)); + +const isWireOrder = (value: unknown): value is LighterCreateOrderWireParams => + Array.isArray(value) && + value.length === 10 && + isInteger(value[0]) && + isInteger(value[1]) && + value[1] > 0 && + value[1] < 2 ** 48 && + isWireDecimal(value[2]) && + isWireDecimal(value[3]) && + Number(value[3]) > 0 && + Number(value[3]) <= LIGHTER_MAX_WIRE_PRICE && + (value[4] === 0 || value[4] === 1) && + isInteger(value[5]) && + Object.hasOwn(venueOrderTypes, value[5]) && + (value[6] === LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL || + value[6] === LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME) && + (value[7] === 0 || value[7] === 1) && + isWireDecimal(value[8]) && + Number(value[8]) <= LIGHTER_MAX_WIRE_PRICE && + (value[9] === LIGHTER_ORDER_EXPIRY_NONE || isInteger(value[9])); + +/** + * Parse local ownership without treating corrupt storage as an empty account. + * + * @param raw - Serialized account journal. + * @returns Strictly validated groups with unchanged unsigned order intent. + */ +export function parseLighterAttachedGroups( + raw: string | null, +): LighterAttachedGroup[] { + if (raw === null) { + return []; + } + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value) || value.length > LIGHTER_ATTACHED_MAX_GROUPS) { + throw new Error('Invalid Lighter attached-order journal'); + } + const ids = new Set(); + const clientIds = new Set(); + for (const item of value) { + if (typeof item !== 'object' || item === null) { + throw new Error('Invalid Lighter attached-order journal'); + } + const group = item as Partial; + if ( + group.version !== 1 || + (group.preparedAt !== undefined && + (!isInteger(group.preparedAt) || group.preparedAt <= 0)) || + (group.acceptanceReviewedAt !== undefined && + (!isInteger(group.acceptanceReviewedAt) || + group.acceptanceReviewedAt <= 0 || + !['accepted', 'completed', 'canceled'].includes( + group.submission ?? '', + ) || + !group.venueIds?.every((id) => id !== null))) || + typeof group.groupId !== 'string' || + !group.groupId.startsWith(LIGHTER_ATTACHED_HANDLE_PREFIX) || + typeof group.symbol !== 'string' || + !group.symbol || + !isInteger(group.accountIndex) || + !isInteger(group.apiKeyIndex) || + group.apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + group.apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !['prepared', 'unknown', 'accepted', 'canceled', 'completed'].includes( + group.submission ?? '', + ) || + !Array.isArray(group.orders) || + ![2, 3].includes(group.orders.length) || + !group.orders.every(isWireOrder) || + !( + group.txHash === null || + (typeof group.txHash === 'string' && /^[\da-f]+$/iu.test(group.txHash)) + ) || + !( + group.expiresAt === null || + (isInteger(group.expiresAt) && group.expiresAt > 0) + ) || + !Array.isArray(group.venueIds) || + group.venueIds.length !== group.orders.length || + !group.venueIds.every( + (id) => + id === null || (typeof id === 'string' && /^\d{1,20}$/u.test(id)), + ) + ) { + throw new Error('Invalid Lighter attached-order journal'); + } + if ( + group.preparedAt !== undefined && + ((group.expiresAt !== null && + group.expiresAt !== undefined && + group.preparedAt > group.expiresAt) || + (group.acceptanceReviewedAt !== undefined && + group.preparedAt > group.acceptanceReviewedAt)) + ) { + throw new Error('Invalid Lighter attached preparation time'); + } + if ( + group.nonAcceptance !== undefined && + (!['failed', 'expired', 'nonce-consumed'].includes(group.nonAcceptance) || + !['unknown', 'canceled'].includes(group.submission ?? '') || + group.txHash === null || + group.nonce === null || + group.expiresAt === null) + ) { + throw new Error('Invalid Lighter attached non-acceptance proof'); + } + if ( + !(group.nonce === null || isInteger(group.nonce)) || + ((group.submission === 'unknown' || + group.submission === 'accepted' || + group.submission === 'completed') && + (group.txHash === null || + group.expiresAt === null || + group.nonce === null)) + ) { + throw new Error('Invalid Lighter attached dispatch identity'); + } + if ( + group.orderExpiries !== undefined && + (!Array.isArray(group.orderExpiries) || + group.orderExpiries.length !== group.orders.length || + !group.orderExpiries.every(isInteger)) + ) { + throw new Error('Invalid Lighter attached order expiries'); + } + if ( + (group.submission === 'unknown' || + group.submission === 'accepted' || + group.submission === 'completed') && + (group.orderExpiries === undefined || + group.orderExpiries.some((expiry, index) => + group.orders?.[index][9] === 0 ? expiry !== 0 : expiry <= 0, + )) + ) { + throw new Error('Invalid Lighter attached signed expiries'); + } + if ( + group.submission === 'completed' && + group.venueIds.some((id) => id === null) + ) { + throw new Error('Invalid Lighter attached completed identity'); + } + const [parent, ...children] = group.orders; + if ( + !parent || + Number(parent[2]) <= 0 || + ![LIGHTER_ORDER_TYPE_LIMIT, LIGHTER_ORDER_TYPE_MARKET].includes( + parent[5], + ) || + parent[7] !== 0 || + parent[8] !== '0' || + children.some( + (child) => + child[0] !== parent[0] || + child[2] !== '0' || + child[4] === parent[4] || + child[7] !== 1 || + ![ + LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_ORDER_TYPE_TAKE_PROFIT, + ].includes(child[5]) || + child[6] !== LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL || + Number(child[8]) <= 0 || + child[9] !== LIGHTER_ORDER_EXPIRY_NONE, + ) || + new Set(group.orders.map((order) => order[1])).size !== + group.orders.length || + new Set(children.map((child) => child[5])).size !== children.length || + ids.has(group.groupId) + ) { + throw new Error('Invalid Lighter attached-order intent'); + } + for (const order of group.orders) { + if (clientIds.has(order[1])) { + throw new Error('Duplicate Lighter attached client identity'); + } + clientIds.add(order[1]); + } + ids.add(group.groupId); + } + return value as LighterAttachedGroup[]; +} + +/** + * Project durable intent without representing client IDs as venue IDs. + * + * @param group - Account-owned durable group. + * @returns Consumer-safe identities and the last observed submission state. + */ +export function toAttachedOrderGroup( + group: LighterAttachedGroup, +): AttachedOrderGroup { + const [parentId, ...childIds] = group.venueIds; + return { + groupId: group.groupId, + providerId: 'lighter', + symbol: group.symbol, + submission: group.submission, + parentClientOrderId: String(group.orders[0][1]), + childClientOrderIds: group.orders.slice(1).map((order) => String(order[1])), + ...(parentId === null ? {} : { parentOrderId: parentId }), + ...(childIds.every((id): id is string => id !== null) + ? { childOrderIds: childIds } + : {}), + cancellation: 'explicit-exact-owned-orders', + }; +} + +/** + * Correlate native children by signed client IDs, never by new-book differences. + * + * @param group - Durable unsigned identities. + * @param rows - Bounded authoritative active and inactive observations. + * @returns One exact venue observation or unknown for each signed leg. + */ +export function correlateLighterAttachedOrders( + group: LighterAttachedGroup, + rows: LighterApiOrder[], +): (LighterApiOrder | null)[] { + return group.orders.map((wire, index) => { + const matches = rows.filter( + (row) => + row.ownerAccountIndex === group.accountIndex && + row.marketIndex === wire[0] && + String(row.clientOrderIndex) === String(wire[1]), + ); + if (matches.length === 0) { + return null; + } + const row = matches[0]; + const id = String(row.orderIndex); + if ( + matches.length !== 1 || + !/^\d{1,20}$/u.test(id) || + (group.venueIds[index] !== null && group.venueIds[index] !== id) || + row.isAsk !== (wire[4] === 1) || + ![0, 1, false, true].includes(row.reduceOnly) || + Boolean(row.reduceOnly) !== (wire[7] === 1) || + row.type !== venueOrderTypes[wire[5]] + ) { + throw new Error( + 'Lighter attached-order identity does not match signed intent', + ); + } + return row; + }); +} diff --git a/packages/perps-controller/src/utils/lighterChase.ts b/packages/perps-controller/src/utils/lighterChase.ts new file mode 100644 index 00000000000..03d43ec4e43 --- /dev/null +++ b/packages/perps-controller/src/utils/lighterChase.ts @@ -0,0 +1,271 @@ +import { BigNumber } from 'bignumber.js'; + +import { + LIGHTER_MAX_DECIMALS, + LIGHTER_MAX_ORDER_PRICE, +} from '../constants/lighterConfig.js'; +import type { + LighterApiOrder, + LighterOrderBookOrdersResponse, + LighterRestTrade, +} from '../types/lighter-types.js'; +import { adaptFillFromLighterTrade } from './lighterAdapter.js'; + +/** Authoritative observation of one exact ordinary post-only child. */ +export type LighterChaseChildObservation = { + orderId: string; + terminal: boolean; + filledSize: string; + filledNotional: string; + remainingSize: string; +}; + +/** + * @param value - Wire decimal amount. + * @returns Nonnegative exact decimal or an explicit integrity error. + */ +function amount(value: string | undefined): BigNumber { + if (value === undefined || !/^\d+(?:\.\d+)?$/u.test(value)) { + throw new Error('Lighter Chase amount is missing or malformed'); + } + const parsed = new BigNumber(value); + if (!parsed.isFinite() || parsed.isNegative()) { + throw new Error('Lighter Chase amount is invalid'); + } + return parsed; +} + +/** + * @param numeric - Numeric native ID (must be lossless). + * @param exact - Optional canonical string representation. + * @returns Exact safe native ID. + */ +function exactId(numeric: number, exact?: string): string { + if ( + !Number.isSafeInteger(numeric) || + numeric <= 0 || + (exact !== undefined && exact !== String(numeric)) + ) { + throw new Error('Lighter Chase identity is invalid'); + } + return String(numeric); +} + +/** + * Quote on the native fixed tick grid after excluding same-side account-owned rows. + * The public endpoint supplies individual orders, so subtraction is exact and + * includes other sessions and ordinary resting orders on the same side. + * + * @param book - Fresh validated native public book response. + * @param options - Native grid, side and account identity. + * @param options.isBuy - Requested side. + * @param options.accountIndex - Account whose own orders must be removed. + * @param options.priceDecimals - Fixed native price precision. + * @returns Noncrossing limit price with no floating-point rounding. + */ +export function readLighterChaseQuote( + book: LighterOrderBookOrdersResponse, + options: { isBuy: boolean; accountIndex: number; priceDecimals: number }, +): string { + if ( + !Number.isSafeInteger(options.priceDecimals) || + options.priceDecimals < 0 || + options.priceDecimals > LIGHTER_MAX_DECIMALS + ) { + throw new Error('Lighter Chase book grid is invalid'); + } + const seen = new Set(); + const external: { bids: BigNumber[]; asks: BigNumber[] } = { + bids: [], + asks: [], + }; + for (const side of ['bids', 'asks'] as const) { + const rows = book[side]; + const total = side === 'bids' ? book.totalBids : book.totalAsks; + if ( + !Number.isSafeInteger(total) || + total < rows.length || + (total > 0 && rows.length === 0) + ) { + throw new Error('Lighter Chase book is incomplete'); + } + for (const row of rows) { + const id = exactId(row.orderIndex, row.orderId); + if ( + seen.has(id) || + !Number.isSafeInteger(row.ownerAccountIndex) || + row.ownerAccountIndex < 0 + ) { + throw new Error('Lighter Chase book identity is ambiguous'); + } + seen.add(id); + const price = amount(row.price); + const size = amount(row.remainingBaseAmount); + const units = price.shiftedBy(options.priceDecimals); + if ( + !units.isInteger() || + units.lt(1) || + units.gt(LIGHTER_MAX_ORDER_PRICE) || + size.lte(0) || + size.gt(amount(row.initialBaseAmount)) + ) { + throw new Error('Lighter Chase book level is invalid'); + } + if ( + row.ownerAccountIndex !== options.accountIndex || + (side === 'bids') !== options.isBuy + ) { + external[side].push(price); + } + } + } + if (external.bids.length === 0 || external.asks.length === 0) { + throw new Error('Lighter Chase external book is unavailable'); + } + const bid = BigNumber.maximum(...external.bids); + const ask = BigNumber.minimum(...external.asks); + if (bid.gte(ask)) { + throw new Error('Lighter Chase book is crossed'); + } + const tick = new BigNumber(1).shiftedBy(-options.priceDecimals); + const price = options.isBuy + ? BigNumber.minimum(bid.plus(tick), ask.minus(tick)) + : BigNumber.maximum(ask.minus(tick), bid.plus(tick)); + if (price.lte(0)) { + throw new Error('Lighter Chase book quote is invalid'); + } + return price.toFixed(); +} + +/** + * Correlate exact native child identity and exhaustive individual fill history. + * Callers must reread the exact child after collecting trades to reject races. + * + * @param intent - Immutable session ownership and market intent. + * @param child - Exact persisted child and signed nonce. + * @param order - Stable authoritative order snapshot. + * @param trades - Fully paginated unaggregated trades for this exact child. + * @returns Exact cumulative fills and terminal state, never invented zero fills. + */ +export function reconcileLighterChaseChild( + intent: { + owner: { accountIndex: number }; + symbol: string; + marketId: number; + isBuy: boolean; + reduceOnly: boolean; + }, + child: { clientOrderId: string; size: string; price: string; nonce?: number }, + order: LighterApiOrder, + trades: readonly LighterRestTrade[], +): LighterChaseChildObservation { + const orderId = identifyLighterChaseChild(intent, child, order); + const unique = new Map(); + let filled = new BigNumber(0); + let notional = new BigNumber(0); + for (const trade of trades) { + exactId(trade.bidId, trade.bidIdStr); + exactId(trade.askId, trade.askIdStr); + const id = exactId(trade.tradeId, trade.tradeIdStr); + const previous = unique.get(id); + if (previous) { + if (JSON.stringify(previous) !== JSON.stringify(trade)) { + throw new Error('Lighter Chase conflicting duplicate fill'); + } + continue; + } + if ( + exactId(intent.isBuy ? trade.bidId : trade.askId) !== orderId || + (intent.isBuy ? trade.bidAccountId : trade.askAccountId) !== + intent.owner.accountIndex || + trade.marketId !== intent.marketId || + !trade.txHash || + !Number.isSafeInteger(trade.timestamp) || + trade.timestamp <= 0 + ) { + throw new Error('Lighter Chase fill identity mismatch'); + } + adaptFillFromLighterTrade(trade, intent.symbol, intent.owner.accountIndex); + unique.set(id, trade); + filled = filled.plus(amount(trade.size)); + notional = notional.plus(amount(trade.usdAmount)); + } + if ( + !filled.eq(amount(order.filledBaseAmount)) || + !notional.eq(amount(order.filledQuoteAmount)) || + filled.gt(child.size) + ) { + throw new Error('Lighter Chase fill totals are incomplete or inconsistent'); + } + const remaining = new BigNumber(child.size).minus(filled); + const active = ['pending', 'in-progress', 'open'].includes(order.status); + const terminal = [ + 'filled', + 'canceled', + 'canceled-post-only', + 'canceled-reduce-only', + 'canceled-position-not-allowed', + 'canceled-margin-not-allowed', + 'canceled-self-trade', + 'canceled-expired', + 'canceled-liquidation', + 'canceled-invalid-balance', + ].includes(order.status); + if (!active && !terminal) { + throw new Error('Lighter Chase child status is unverified'); + } + if (order.status === 'filled' && !remaining.isZero()) { + throw new Error('Lighter Chase filled child is underfilled'); + } + const rawRemaining = amount(order.remainingBaseAmount); + if (rawRemaining.gt(remaining) || (active && !rawRemaining.eq(remaining))) { + throw new Error('Lighter Chase remaining quantity is inconsistent'); + } + return { + orderId, + terminal, + filledSize: filled.toFixed(), + filledNotional: notional.toFixed(), + remainingSize: remaining.toFixed(), + }; +} + +/** + * Validate exact immutable child ownership independently of execution reads. + * Venue order nonces are not API-key transaction nonces. The provider verifies + * the signed nonce against exact transaction evidence during dispatch recovery. + * + * @param intent - Immutable session ownership. + * @param child - Persisted signed child. + * @param order - Exact client-ID venue lookup. + * @returns The exact venue order ID eligible for cancellation. + */ +export function identifyLighterChaseChild( + intent: { + owner: { accountIndex: number }; + symbol: string; + marketId: number; + isBuy: boolean; + reduceOnly: boolean; + }, + child: { clientOrderId: string; size: string; price: string; nonce?: number }, + order: LighterApiOrder, +): string { + const orderId = exactId(order.orderIndex, order.orderId); + if ( + exactId(order.clientOrderIndex, order.clientOrderId) !== + child.clientOrderId || + order.ownerAccountIndex !== intent.owner.accountIndex || + order.marketIndex !== intent.marketId || + order.isAsk === intent.isBuy || + order.type !== 'limit' || + order.timeInForce !== 'post-only' || + Boolean(order.reduceOnly) !== intent.reduceOnly || + child.nonce === undefined || + !amount(order.initialBaseAmount).eq(child.size) || + !amount(order.price).eq(child.price) + ) { + throw new Error('Lighter Chase child identity mismatch'); + } + return orderId; +} diff --git a/packages/perps-controller/src/utils/lighterLosslessJson.ts b/packages/perps-controller/src/utils/lighterLosslessJson.ts new file mode 100644 index 00000000000..4b27545fac3 --- /dev/null +++ b/packages/perps-controller/src/utils/lighterLosslessJson.ts @@ -0,0 +1,139 @@ +const MAX_JSON_LENGTH = 2_000_000; +const MAX_JSON_DEPTH = 32; + +/** + * Decode venue JSON without rounding integer identity tokens. Unsafe integers + * remain decimal strings. Uses only ordinary string parsing available on Hermes; + * no JSON reviver source extension, BigInt serialization or payload rewriting. + * Duplicate keys are refused instead of choosing one financial identity. + * + * @param raw - Untrusted JSON response or signed transaction. + * @returns Parsed JSON with exact integer identities. + */ +export function parseLighterLosslessJson(raw: string): unknown { + if (typeof raw !== 'string' || raw.length > MAX_JSON_LENGTH) { + throw new Error('Invalid Lighter JSON length'); + } + let offset = 0; + const fail = (): never => { + throw new Error('Invalid Lighter JSON'); + }; + const whitespace = (): void => { + while (/[\t\n\r ]/u.test(raw[offset] ?? 'x')) { + offset += 1; + } + }; + const readString = (): string => { + if (raw[offset] !== '"') { + return fail(); + } + const start = offset; + offset += 1; + while (offset < raw.length) { + const character = raw[offset]; + offset += 1; + if (character === '\\') { + offset += 1; + } else if (character === '"') { + try { + const value: unknown = JSON.parse(raw.slice(start, offset)); + return typeof value === 'string' ? value : fail(); + } catch { + return fail(); + } + } + } + return fail(); + }; + const value = (depth: number): unknown => { + if (depth > MAX_JSON_DEPTH) { + return fail(); + } + whitespace(); + const character = raw[offset]; + if (character === '"') { + return readString(); + } + if (character === '{' || character === '[') { + const object = character === '{'; + const closing = object ? '}' : ']'; + const entries = Object.create(null) as Record; + const items: unknown[] = []; + offset += 1; + whitespace(); + if (raw[offset] === closing) { + offset += 1; + return object ? entries : items; + } + while (offset < raw.length) { + whitespace(); + if (object) { + const key = readString(); + if (Object.prototype.hasOwnProperty.call(entries, key)) { + return fail(); + } + whitespace(); + if (raw[offset] !== ':') { + return fail(); + } + offset += 1; + entries[key] = value(depth + 1); + } else { + items.push(value(depth + 1)); + } + whitespace(); + const separator = raw[offset]; + offset += 1; + if (separator === closing) { + return object ? entries : items; + } + if (separator !== ',') { + return fail(); + } + } + return fail(); + } + for (const [literal, parsed] of [ + ['null', null], + ['true', true], + ['false', false], + ] as const) { + if (raw.startsWith(literal, offset)) { + offset += literal.length; + return parsed; + } + } + const token = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec( + raw.slice(offset), + )?.[0]; + if (token === undefined) { + return fail(); + } + offset += token.length; + const numeric = Number(token); + if (/^-?\d+$/u.test(token) && !Number.isSafeInteger(numeric)) { + return token; + } + if ( + !Number.isFinite(numeric) || + Math.abs(numeric) > Number.MAX_SAFE_INTEGER + ) { + return fail(); + } + // An integer-looking number can hide a rounded fraction or exponent. + // Identity consumers accept only canonical integer tokens. + if ( + Number.isInteger(numeric) && + !/^(?:0|[1-9]\d*|-[1-9]\d*)$/u.test(token) + ) { + return token; + } + return numeric; + }; + const result = value(0); + whitespace(); + if (offset !== raw.length) { + return fail(); + } + return result; +} diff --git a/packages/perps-controller/src/utils/lighterNativeEdit.ts b/packages/perps-controller/src/utils/lighterNativeEdit.ts new file mode 100644 index 00000000000..a9884a11ad5 --- /dev/null +++ b/packages/perps-controller/src/utils/lighterNativeEdit.ts @@ -0,0 +1,729 @@ +import { + assert, + object, + string, + number, + boolean, + enums, + optional, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_MAX_ORDER_ID, + LIGHTER_MAX_WIRE_PRICE, + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_TRADING_API_KEY_INDEX, +} from '../constants/lighterConfig.js'; +import { isLighterTxHash } from '../services/lighterDispatchIdentity.js'; +import type { OrderParams, OrderResult } from '../types/index.js'; +import type { + LighterEditableOrder, + LighterOrderBookMeta, + LighterSignModifyOrderWireParams, +} from '../types/lighter-types.js'; +import { adaptOrderStatus } from './lighterAdapter.js'; +import { parseLighterLosslessJson } from './lighterLosslessJson.js'; + +const MODIFY_MARKET_MAX = 32_767; +const MODIFY_EXPIRY_OFFSET_MS = 599_000; +const MODIFY_SIGNATURE_BYTES = 80; +export const LIGHTER_EDIT_JOURNAL_PREFIX = 'lighterNativeEdit:'; + +/** Exact order identity accepted by the embedded int64 signer. + * @param value - Caller or venue identity. + * @returns Canonical positive decimal identity. + */ +export function lighterEditOrderId(value: unknown): string { + if ( + typeof value === 'number' && + (!Number.isSafeInteger(value) || value <= 0) + ) { + throw new Error('Lighter edit requires a lossless order identity'); + } + if ( + (typeof value !== 'number' && typeof value !== 'string') || + !/^[1-9]\d*$/u.test(String(value)) || + String(value).length > 19 || + BigInt(value) > BigInt(LIGHTER_MAX_ORDER_ID) + ) { + throw new Error('Lighter edit order identity is invalid'); + } + return String(value); +} + +/** Strict fixed-grid integerization without truncation or unsigned wrapping. + * @param value - Exact decimal quantity or price. + * @param decimals - Current venue grid. + * @param maximum - Embedded signer width. + * @returns Exact integer accepted by the JavaScript ABI. + */ +function units( + value: string, + decimals: number, + maximum: string | number, +): number { + if ( + typeof value !== 'string' || + !Number.isSafeInteger(decimals) || + decimals < 0 || + decimals > 18 || + !/^\d+(?:\.\d+)?$/u.test(value) + ) { + throw new Error('Lighter edit grid or decimal is invalid'); + } + const result = new BigNumber(value).shiftedBy(decimals); + if ( + !result.isInteger() || + result.lt(1) || + result.gt(maximum) || + result.gt(Number.MAX_SAFE_INTEGER) + ) { + throw new Error('Lighter edit value is outside its exact native grid'); + } + return result.toNumber(); +} + +/** Validated immutable edit intent; no signature or secret is persisted. */ +export type LighterNativeEditIntent = { + symbol: string; + orderId: string; + marketIndex: number; + price: string; + size: string; + priceInt: number; + sizeInt: number; + isAsk: boolean; + reduceOnly: boolean; + timeInForce: string; +}; + +/** Refuse unsupported order identities or mutations the ABI cannot encode. + * @param params - Explicit replacement fields. + * @param row - Fresh exact ordinary resting target. + * @param market - Refreshed active perpetual metadata. + * @param accountIndex - Captured venue account. + * @returns Exact unsigned edit intent. + */ +export function prepareLighterNativeEdit( + params: OrderParams, + row: LighterEditableOrder, + market: LighterOrderBookMeta, + accountIndex: number, +): LighterNativeEditIntent { + const allowed = new Set([ + 'symbol', + 'isBuy', + 'size', + 'price', + 'orderType', + 'reduceOnly', + 'timeInForce', + 'providerId', + 'trackingData', + ]); + if ( + Object.entries(params).some( + ([key, value]) => value !== undefined && !allowed.has(key), + ) + ) { + throw new Error('Lighter native edit contains unsupported order fields'); + } + const orderId = lighterEditOrderId(row.orderIndex); + if ( + !Number.isSafeInteger(accountIndex) || + accountIndex < 0 || + accountIndex > Number(LIGHTER_MAX_BASE_AMOUNT) || + !Number.isSafeInteger(row.clientOrderIndex) || + row.clientOrderIndex <= 0 || + row.clientOrderIndex > Number(LIGHTER_MAX_BASE_AMOUNT) || + !Number.isSafeInteger(row.orderExpiry) || + row.orderExpiry < 0 || + (row.orderExpiry !== 0 && row.orderExpiry <= Date.now()) + ) { + throw new Error('Lighter editable target identity or expiry is invalid'); + } + if ( + row.orderId !== undefined && + lighterEditOrderId(row.orderId) !== orderId + ) { + throw new Error('Lighter edit order identities disagree'); + } + const reduceOnly = row.reduceOnly === true || row.reduceOnly === 1; + if ( + (params.providerId !== undefined && params.providerId !== 'lighter') || + typeof params.isBuy !== 'boolean' || + typeof row.isAsk !== 'boolean' || + market.status !== 'active' || + market.marketType !== 'perp' || + !Number.isSafeInteger(market.marketId) || + market.marketId < 0 || + market.marketId > MODIFY_MARKET_MAX || + market.marketId === 255 || + row.marketIndex !== market.marketId || + row.ownerAccountIndex !== accountIndex || + params.symbol !== market.symbol || + params.orderType !== 'limit' || + row.type !== 'limit' || + row.status !== 'open' || + params.isBuy === row.isAsk || + ![0, 1, false, true].includes(row.reduceOnly) || + (params.reduceOnly !== undefined && params.reduceOnly !== reduceOnly) + ) { + throw new Error( + 'Lighter edit requires the same active ordinary limit identity', + ); + } + const tifByVenue: Record = { + 'good-till-time': 'GTC', + 'post-only': 'ALO', + }; + const tif = tifByVenue[row.timeInForce] ?? null; + if ( + tif === null || + (params.timeInForce !== undefined && params.timeInForce !== tif) || + [ + row.parentOrderId, + row.toCancelOrderId0, + row.toTriggerOrderId0, + row.toTriggerOrderId1, + ].some((id) => id !== undefined && id !== '' && id !== '0') || + (row.parentOrderIndex !== undefined && + String(row.parentOrderIndex) !== '0') || + (row.triggerPrice !== undefined && + !new BigNumber(row.triggerPrice).isZero()) + ) { + throw new Error( + 'Lighter native edit cannot change linked or trigger protection', + ); + } + // The pinned ABI has no order-version input. Refuse prior fills rather than + // guessing whether its amount would replace total or remaining exposure. + if ( + !new BigNumber(row.initialBaseAmount).eq(row.remainingBaseAmount) || + row.filledBaseAmount === undefined || + !new BigNumber(row.filledBaseAmount).isZero() + ) { + throw new Error('Lighter edit requires an unfilled resting order'); + } + const price = params.price ?? row.price; + const size = params.size ?? row.remainingBaseAmount; + const priceInt = units( + price, + market.supportedPriceDecimals, + LIGHTER_MAX_WIRE_PRICE, + ); + const sizeInt = units( + size, + market.supportedSizeDecimals, + LIGHTER_MAX_BASE_AMOUNT, + ); + if ( + !new BigNumber(market.minBaseAmount).gt(0) || + !new BigNumber(market.minQuoteAmount).gt(0) || + new BigNumber(size).lt(market.minBaseAmount) || + new BigNumber(size).times(price).lt(market.minQuoteAmount) + ) { + throw new Error('Lighter edited order is below venue minimums'); + } + if ( + new BigNumber(price).eq(row.price) && + new BigNumber(size).eq(row.initialBaseAmount) + ) { + throw new Error('Lighter native edit requires a changed price or size'); + } + return { + symbol: market.symbol, + orderId, + marketIndex: market.marketId, + price: new BigNumber(price).toFixed(), + size: new BigNumber(size).toFixed(), + priceInt, + sizeInt, + isAsk: row.isAsk, + reduceOnly, + timeInForce: row.timeInForce, + }; +} + +/** Validate the actual signed response while preserving original transport bytes. + * @param result - Untrusted bridge result. + * @param tuple - Exact seven-position request. + * @param apiKeyIndex - Captured initialized signer slot. + * @param startedAt - Host time before signing. + * @param completedAt - Host time after signing. + * @returns Exact original payload plus validated dispatch identity. + */ +export function decodeLighterModifyOrder( + result: unknown, + tuple: LighterSignModifyOrderWireParams, + apiKeyIndex: number, + startedAt: number, + completedAt: number, +): { txInfo: string; txHash: string; expiresAt: number } { + const fail = (): never => { + throw new Error('Lighter native edit signer response is invalid'); + }; + if ( + !Array.isArray(tuple) || + tuple.length !== 7 || + !Number.isSafeInteger(tuple[0]) || + tuple[0] < 0 || + tuple[0] > Number(LIGHTER_MAX_BASE_AMOUNT) || + !Number.isSafeInteger(tuple[1]) || + tuple[1] < 0 || + tuple[1] > MODIFY_MARKET_MAX || + tuple[1] === 255 || + lighterEditOrderId(tuple[2]) !== tuple[2] || + !Number.isSafeInteger(tuple[3]) || + tuple[3] <= 0 || + tuple[3] > Number(LIGHTER_MAX_BASE_AMOUNT) || + !Number.isSafeInteger(tuple[4]) || + tuple[4] <= 0 || + tuple[4] > LIGHTER_MAX_WIRE_PRICE || + tuple[5] !== 0 || + !Number.isSafeInteger(tuple[6]) || + tuple[6] < 0 || + !Number.isSafeInteger(apiKeyIndex) || + apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !Number.isSafeInteger(startedAt) || + startedAt <= 0 || + !Number.isSafeInteger(completedAt) || + completedAt < startedAt + ) { + return fail(); + } + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return fail(); + } + const response = result as Record; + if ( + Object.keys(response).sort().join(',') !== 'txHash,txInfo' || + typeof response.txInfo !== 'string' || + !isLighterTxHash(response.txHash) + ) { + return fail(); + } + const decoded = parseLighterLosslessJson(response.txInfo); + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + return fail(); + } + const tx = decoded as Record; + const expected = { + AccountIndex: tuple[0], + ApiKeyIndex: apiKeyIndex, + MarketIndex: tuple[1], + Index: tuple[2], + BaseAmount: tuple[3], + Price: tuple[4], + TriggerPrice: tuple[5], + Nonce: tuple[6], + }; + if ( + Object.keys(tx).sort().join(',') !== + [...Object.keys(expected), 'ExpiredAt', 'Sig', 'L2TxAttributes'] + .sort() + .join(',') || + tx.L2TxAttributes !== null + ) { + return fail(); + } + for (const [key, wanted] of Object.entries(expected)) { + const actual = tx[key]; + if ( + !( + (typeof actual === 'number' && + Number.isSafeInteger(actual) && + actual >= 0) || + (typeof actual === 'string' && /^(?:0|[1-9]\d*)$/u.test(actual)) + ) || + BigInt(actual) !== BigInt(wanted) + ) { + return fail(); + } + } + if ( + typeof tx.ExpiredAt !== 'number' || + !Number.isSafeInteger(tx.ExpiredAt) || + tx.ExpiredAt < startedAt + MODIFY_EXPIRY_OFFSET_MS || + tx.ExpiredAt > completedAt + MODIFY_EXPIRY_OFFSET_MS || + typeof tx.Sig !== 'string' + ) { + return fail(); + } + try { + const bytes = base64ToBytes(tx.Sig); + if ( + bytes.length !== MODIFY_SIGNATURE_BYTES || + bytesToBase64(bytes) !== tx.Sig + ) { + return fail(); + } + } catch { + return fail(); + } + return { + txInfo: response.txInfo, + txHash: response.txHash, + expiresAt: tx.ExpiredAt, + }; +} + +const TargetStruct = object({ + orderId: string(), + clientOrderId: string(), + marketIndex: number(), + accountIndex: number(), + price: string(), + size: string(), + isAsk: boolean(), + reduceOnly: boolean(), + timeInForce: string(), + orderExpiry: number(), +}); +const IntentStruct = object({ + symbol: string(), + orderId: string(), + marketIndex: number(), + price: string(), + size: string(), + priceInt: number(), + sizeInt: number(), + isAsk: boolean(), + reduceOnly: boolean(), + timeInForce: string(), +}); +const EditJournalStruct = object({ + version: enums([1]), + wallet: string(), + network: enums(['testnet', 'mainnet']), + accountIndex: number(), + apiKeyIndex: number(), + startedAt: number(), + original: TargetStruct, + intent: IntentStruct, + phase: enums(['prepared', 'signed', 'attempted', 'accepted']), + status: enums(['pending', 'settled', 'failed', 'terminal']), + nonce: optional(number()), + txHash: optional(string()), + expiresAt: optional(number()), + resolution: optional(enums(['executed', 'failed', 'expired', 'unsent'])), + observation: optional( + object({ + price: string(), + size: string(), + remainingSize: string(), + filledSize: string(), + status: string(), + }), + ), +}); +export type LighterNativeEditJournal = Infer; +export type LighterNativeEditTarget = Infer; + +/** Capture only plain immutable target fields; never persist raw venue data or signed payloads. + * @param row - Verified active target. + * @returns Exact original fingerprint. + */ +export function lighterNativeEditTarget( + row: LighterEditableOrder, +): LighterNativeEditTarget { + return { + orderId: lighterEditOrderId(row.orderIndex), + clientOrderId: String(row.clientOrderIndex), + marketIndex: row.marketIndex, + accountIndex: row.ownerAccountIndex, + price: row.price, + size: row.initialBaseAmount, + isAsk: row.isAsk, + reduceOnly: row.reduceOnly === 1 || row.reduceOnly === true, + timeInForce: row.timeInForce, + orderExpiry: row.orderExpiry, + }; +} + +/** Reject malformed or foreign durable attempts instead of clearing retry obligations. + * @param raw - Local journal text. + * @param scope - Issuing wallet/network/account/order identity. + * @returns Validated retained obligation, or null for absent storage. + */ +export function parseLighterNativeEditJournal( + raw: string | null, + scope: { + wallet: string; + network: 'testnet' | 'mainnet'; + accountIndex: number; + orderId: string; + }, +): LighterNativeEditJournal | null { + if (raw === null) { + return null; + } + const record: unknown = parseLighterLosslessJson(raw); + assert(record, EditJournalStruct); + if ( + record.wallet !== scope.wallet || + record.network !== scope.network || + record.accountIndex !== scope.accountIndex || + !Number.isSafeInteger(scope.accountIndex) || + scope.accountIndex < 0 || + scope.accountIndex > Number(LIGHTER_MAX_BASE_AMOUNT) || + record.intent.symbol.length === 0 || + record.intent.symbol.length > 100 || + record.original.accountIndex !== scope.accountIndex || + record.original.orderId !== scope.orderId || + record.intent.orderId !== scope.orderId || + record.intent.marketIndex !== record.original.marketIndex || + record.intent.isAsk !== record.original.isAsk || + record.intent.reduceOnly !== record.original.reduceOnly || + record.intent.timeInForce !== record.original.timeInForce || + !Number.isSafeInteger(record.startedAt) || + record.startedAt <= 0 || + !Number.isSafeInteger(record.apiKeyIndex) || + record.apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + record.apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !/^[1-9]\d*$/u.test(record.original.clientOrderId) || + record.original.clientOrderId.length > 15 || + BigInt(record.original.clientOrderId) > BigInt(LIGHTER_MAX_BASE_AMOUNT) || + !Number.isSafeInteger(record.intent.marketIndex) || + record.intent.marketIndex < 0 || + record.intent.marketIndex > MODIFY_MARKET_MAX || + record.intent.marketIndex === 255 || + !Number.isSafeInteger(record.original.orderExpiry) || + record.original.orderExpiry < 0 || + !['good-till-time', 'post-only'].includes(record.original.timeInForce) + ) { + throw new Error('Lighter native edit journal ownership is corrupt'); + } + lighterEditOrderId(scope.orderId); + for (const value of [ + record.original.price, + record.original.size, + record.intent.price, + record.intent.size, + ]) { + if ( + !/^\d+(?:\.\d+)?$/u.test(value) || + !new BigNumber(value).gt(0) || + !new BigNumber(value).isFinite() + ) { + throw new Error('Lighter native edit journal quantity is corrupt'); + } + } + if ( + !Number.isSafeInteger(record.intent.priceInt) || + record.intent.priceInt <= 0 || + record.intent.priceInt > LIGHTER_MAX_WIRE_PRICE || + !Number.isSafeInteger(record.intent.sizeInt) || + record.intent.sizeInt <= 0 || + record.intent.sizeInt > Number(LIGHTER_MAX_BASE_AMOUNT) || + (record.phase !== 'prepared' && + (!Number.isSafeInteger(record.nonce) || + (record.nonce ?? -1) < 0 || + !isLighterTxHash(record.txHash) || + !Number.isSafeInteger(record.expiresAt) || + (record.expiresAt ?? 0) <= record.startedAt)) + ) { + throw new Error('Lighter native edit journal dispatch is corrupt'); + } + if (record.observation) { + adaptOrderStatus(record.observation.status); + for (const value of [ + record.observation.price, + record.observation.size, + record.observation.remainingSize, + record.observation.filledSize, + ]) { + if ( + !/^\d+(?:\.\d+)?$/u.test(value) || + !new BigNumber(value).isFinite() || + new BigNumber(value).isNegative() + ) { + throw new Error('Lighter native edit observation is corrupt'); + } + } + } + if ( + (record.phase === 'prepared' && + (record.nonce !== undefined || + record.txHash !== undefined || + record.expiresAt !== undefined)) || + (record.status === 'pending' && + record.resolution !== undefined && + record.resolution !== 'executed') || + (record.status === 'failed' && + !['failed', 'expired', 'unsent'].includes(record.resolution ?? '')) || + (['settled', 'terminal'].includes(record.status) && + (record.resolution !== 'executed' || + !['attempted', 'accepted'].includes(record.phase))) + ) { + throw new Error('Lighter native edit resolution is corrupt'); + } + if ( + (record.status === 'settled' && + (record.observation?.status !== 'open' || + !new BigNumber(record.observation.price).eq(record.intent.price) || + !new BigNumber(record.observation.size).eq(record.intent.size) || + !new BigNumber(record.observation.filledSize).isZero() || + !new BigNumber(record.observation.remainingSize).eq( + record.intent.size, + ))) || + (record.status === 'terminal' && + (!record.observation || + !['filled', 'canceled', 'rejected'].includes( + adaptOrderStatus(record.observation.status), + ))) + ) { + throw new Error('Lighter native edit settlement is corrupt'); + } + return record; +} + +/** + * Validate an exact same-order observation without inventing missing fills. + * @param row - Exact client lookup result. + * @param record - Original edit ownership and immutable target. + * @returns Safe observed fields, independent of transaction settlement. + */ +export function observeLighterNativeEdit( + row: LighterEditableOrder, + record: LighterNativeEditJournal, +): NonNullable { + const { original } = record; + if ( + lighterEditOrderId(row.orderIndex) !== original.orderId || + (row.orderId !== undefined && + lighterEditOrderId(row.orderId) !== original.orderId) || + String(row.clientOrderIndex) !== original.clientOrderId || + row.ownerAccountIndex !== original.accountIndex || + row.marketIndex !== original.marketIndex || + row.isAsk !== original.isAsk || + ![0, 1, false, true].includes(row.reduceOnly) || + (row.reduceOnly === 1 || row.reduceOnly === true) !== original.reduceOnly || + row.timeInForce !== original.timeInForce || + row.orderExpiry !== original.orderExpiry || + row.type !== 'limit' || + [ + row.parentOrderId, + row.toCancelOrderId0, + row.toTriggerOrderId0, + row.toTriggerOrderId1, + ].some((id) => id !== undefined && id !== '' && id !== '0') || + (row.parentOrderIndex !== undefined && + String(row.parentOrderIndex) !== '0') || + (row.triggerPrice !== undefined && + !new BigNumber(row.triggerPrice).isZero()) + ) { + throw new Error('Lighter native edit observation has a different identity'); + } + for (const value of [ + row.price, + row.initialBaseAmount, + row.remainingBaseAmount, + row.filledBaseAmount, + ]) { + if ( + typeof value !== 'string' || + !/^\d+(?:\.\d+)?$/u.test(value) || + !new BigNumber(value).isFinite() || + new BigNumber(value).isNegative() + ) { + throw new Error('Lighter native edit observation is incomplete'); + } + } + const status = adaptOrderStatus(row.status); + if ( + !new BigNumber(row.price).gt(0) || + !new BigNumber(row.initialBaseAmount).gt(0) || + new BigNumber(row.remainingBaseAmount) + .plus(row.filledBaseAmount ?? '') + .gt(row.initialBaseAmount) || + (status === 'open' && + !new BigNumber(row.remainingBaseAmount) + .plus(row.filledBaseAmount ?? '') + .eq(row.initialBaseAmount)) || + (status === 'filled' && + (!new BigNumber(row.remainingBaseAmount).isZero() || + !new BigNumber(row.filledBaseAmount ?? '').eq(row.initialBaseAmount))) + ) { + throw new Error('Lighter native edit observation quantities disagree'); + } + return { + price: row.price, + size: row.initialBaseAmount, + remainingSize: row.remainingBaseAmount, + filledSize: row.filledBaseAmount ?? '', + status: row.status, + }; +} + +/** + * Project only the edit intent and verified observation. A retry may request + * different fields; resolving earlier intent must not report that new intent done. + * @param record - Validated durable outcome. + * @param params - Current caller request. + * @returns Existing order result with additive edit state. + */ +export function lighterNativeEditResult( + record: LighterNativeEditJournal, + params: OrderParams, +): OrderResult { + const allowed = new Set([ + 'symbol', + 'isBuy', + 'size', + 'price', + 'orderType', + 'reduceOnly', + 'timeInForce', + 'providerId', + 'trackingData', + ]); + const sameIntent = + params.symbol === record.intent.symbol && + (params.providerId === undefined || params.providerId === 'lighter') && + !Object.entries(params).some( + ([key, value]) => value !== undefined && !allowed.has(key), + ) && + params.orderType === 'limit' && + params.isBuy === !record.intent.isAsk && + (params.price === undefined || + new BigNumber(params.price).eq(record.intent.price)) && + (params.size === undefined || + new BigNumber(params.size).eq(record.intent.size)) && + (params.reduceOnly === undefined || + params.reduceOnly === record.intent.reduceOnly) && + (params.timeInForce === undefined || + params.timeInForce === + (record.intent.timeInForce === 'post-only' ? 'ALO' : 'GTC')); + const success = record.status === 'settled' && sameIntent; + return { + success, + orderId: record.original.orderId, + providerId: 'lighter', + orderEdit: { + status: record.status, + requestedPrice: record.intent.price, + requestedSize: record.intent.size, + ...(record.observation + ? { + observation: { + ...record.observation, + status: adaptOrderStatus(record.observation.status), + }, + } + : {}), + }, + ...(success + ? {} + : { + error: sameIntent + ? `Lighter native edit is ${record.status}; refresh this exact order before retrying` + : 'An earlier Lighter edit was reconciled; the different requested edit was not submitted', + }), + }; +} diff --git a/packages/perps-controller/src/utils/lighterScaleOrders.ts b/packages/perps-controller/src/utils/lighterScaleOrders.ts new file mode 100644 index 00000000000..8c4c3c29d6f --- /dev/null +++ b/packages/perps-controller/src/utils/lighterScaleOrders.ts @@ -0,0 +1,686 @@ +import { BigNumber } from 'bignumber.js'; + +import { + LIGHTER_MAX_WIRE_PRICE, + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_TRADING_API_KEY_INDEX, +} from '../constants/lighterConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { + OrderParams, + ExpectedScaleLadder, + OrderResult, + ScaleOrderGroup, +} from '../types/index.js'; +import type { LighterOrderBookMeta } from '../types/lighter-types.js'; +import { + computeScalePriceLadder, + splitScaleSizes, +} from './orderCalculations.js'; +import { SCALE_ORDER_COUNT } from './orderTypes.js'; + +const LIGHTER_SCALE_MAX_DECIMALS = 18; +const LIGHTER_SCALE_MAX_JOURNAL_LENGTH = 2_000_000; +const LIGHTER_SCALE_MAX_GROUP_ID_LENGTH = 256; +const LIGHTER_SCALE_MAX_SYMBOL_LENGTH = 100; +const LIGHTER_SCALE_CLIENT_ID_LIMIT = 2 ** 48; +const LIGHTER_SCALE_MAX_HASH_LENGTH = 128; +export const LIGHTER_SCALE_JOURNAL_PREFIX = 'lighterScaleOrders:'; + +export const LIGHTER_SCALE_PREFIX = 'lighter-scale:'; +export const LIGHTER_SCALE_MAX_GROUPS = 64; + +export type LighterScaleRung = { + clientOrderId: number; + price: string; + size: string; + priceInt: number; + sizeInt: number; + state: + | 'prepared' + | 'unknown' + | 'submitted' + | 'accepted' + | 'resting' + | 'filled' + | 'canceled' + | 'rejected'; + nonAcceptance?: 'failed' | 'expired' | 'nonce-consumed'; + orderExpiry: number | null; + nonce: number | null; + txHash: string | null; + expiresAt: number | null; + orderId?: string; + filledSize?: string; + cancelAttempt?: { + acknowledged?: boolean; + apiKeyIndex: number; + nonce: number; + txHash: string; + expiresAt: number; + }; +}; +export type LighterScaleGroup = { + version: 1; + groupId: string; + symbol: string; + accountIndex: number; + apiKeyIndex: number; + marketId: number; + isBuy: boolean; + reduceOnly: boolean; + createdAt: number; + walletAddress: string; + network: 'mainnet' | 'testnet'; + sizeDecimals: number; + priceDecimals: number; + placementStopped: boolean; + rungs: LighterScaleRung[]; +}; + +/** Parse a full positive decimal without binary rounding or numeric prefixes. + * @param raw - Caller or venue decimal. + * @returns Exact positive quantity. + */ +export function parseScaleDecimal(raw: unknown): BigNumber { + if ( + typeof raw !== 'string' || + !/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u.test(raw) + ) { + throw new Error('Invalid Lighter Scale decimal'); + } + const value = new BigNumber(raw); + if (!value.isFinite() || !value.gt(0)) { + throw new Error('Lighter Scale values must be positive'); + } + return value; +} + +/** Normalize inclusive ascending prices to the fixed venue grid. + * @param min - Exact lower price. + * @param max - Exact upper price. + * @param count - Number of rungs. + * @param decimals - Venue price precision. + * @returns Distinct canonical price strings. + */ +export function normalizeLighterScalePrices( + min: string, + max: string, + count: number, + decimals: number, +): string[] { + if ( + !Number.isInteger(decimals) || + decimals < 0 || + decimals > LIGHTER_SCALE_MAX_DECIMALS + ) { + throw new Error('Invalid Lighter Scale precision'); + } + let low: BigNumber; + let high: BigNumber; + try { + low = parseScaleDecimal(min); + high = parseScaleDecimal(max); + } catch { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + if ( + !low.shiftedBy(decimals).isInteger() || + !high.shiftedBy(decimals).isInteger() + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + computeScalePriceLadder({ + minPrice: low.toNumber(), + maxPrice: high.toNumber(), + count, + }); + const prices = Array.from({ length: count }, (_, index) => + low + .plus( + high + .minus(low) + .times(index) + .div(count - 1), + ) + .decimalPlaces(decimals, BigNumber.ROUND_DOWN), + ); + const wire = prices.map((price) => price.shiftedBy(decimals)); + if ( + wire.some( + (price) => + !price.isInteger() || price.lt(1) || price.gt(LIGHTER_MAX_WIRE_PRICE), + ) || + new Set(wire.map(String)).size !== count + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + return prices.map((price) => price.toFixed()); +} + +/** Capture a preview constraint before the first asynchronous operation. + * @param expected - Caller-supplied normalized intent. + * @returns An independent snapshot, or an explicit malformed-preview refusal. + */ +export function captureExpectedScaleLadder( + expected: ExpectedScaleLadder | undefined, +): ExpectedScaleLadder | undefined { + if (expected === undefined) { + return undefined; + } + if (expected === null || typeof expected !== 'object') { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE); + } + const { prices, sizes } = expected; + if ( + !Array.isArray(expected.prices) || + !Array.isArray(expected.sizes) || + Object.keys(expected).some( + (key) => + ![ + 'prices', + 'sizes', + 'totalSize', + 'totalNotional', + 'minimumBaseSize', + 'minimumQuoteAmount', + 'sizeDecimals', + ].includes(key), + ) + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE); + } + return { + ...expected, + prices: [...prices], + sizes: [...sizes], + }; +} + +/** Own financial request values before routing or fee resolution can yield. + * @param params - Caller-owned order request. + * @returns An independent request preserving legacy preview omission. + */ +export function captureScaleOrderParams(params: OrderParams): OrderParams { + const captured = { ...params }; + if (captured.expectedScaleLadder !== undefined) { + captured.expectedScaleLadder = captureExpectedScaleLadder( + captured.expectedScaleLadder, + ); + } + return captured; +} + +/** Refuse any change to the approved normalized financial intent. + * @param expected - Optional caller constraint; omission is legacy unbound placement. + * @param ladder - Fresh normalized placement quantities and prices. + * @param market - Fresh venue minimums and quantity grid. + */ +export function assertExpectedScaleLadder( + expected: ExpectedScaleLadder | undefined, + ladder: { prices: string[]; sizes: string[]; size: string; notional: string }, + market: LighterOrderBookMeta, +): void { + if (expected === undefined) { + return; + } + const captured = captureExpectedScaleLadder(expected); + if ( + !captured || + captured.prices.length !== ladder.prices.length || + captured.sizes.length !== ladder.sizes.length || + ladder.prices.some((price, index) => price !== captured.prices[index]) || + ladder.sizes.some((size, index) => size !== captured.sizes[index]) || + captured.totalSize !== ladder.size || + captured.totalNotional !== ladder.notional || + captured.minimumBaseSize !== market.minBaseAmount || + captured.minimumQuoteAmount !== market.minQuoteAmount || + captured.sizeDecimals !== market.supportedSizeDecimals + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE); + } +} + +/** Build all rungs and prove exact base and quote bounds before any write. + * @param params - Scale intent. + * @param market - Active venue metadata. + * @returns Exact normalized rungs and totals. + */ +export function buildLighterScaleLadder( + params: Omit & + Partial>, + market: LighterOrderBookMeta, +): { prices: string[]; sizes: string[]; size: string; notional: string } { + const unsupported = [ + 'price', + 'triggerPrice', + 'timeInForce', + 'takeProfitPrice', + 'stopLossPrice', + 'takeProfitSize', + 'stopLossSize', + 'tpslLinkage', + 'grouping', + 'clientOrderId', + 'twapDuration', + 'twapRandomize', + 'chaseIntervalMs', + 'chaseMaxDurationMs', + 'chaseMaxRepricings', + 'chaseMaxDistanceBps', + 'marginMode', + 'slippage', + 'maxSlippageBps', + 'priceAtCalculation', + 'isFullClose', + ] as const; + const field = unsupported.find((key) => params[key] !== undefined); + if (field) { + throw new Error(`Lighter Scale does not support ${field}`); + } + if ( + market.status !== 'active' || + market.marketType !== 'perp' || + params.orderType !== 'scale' + ) { + throw new Error('Lighter Scale requires an active perpetual market'); + } + const { + scaleNumOrders: count, + scaleMinPrice: min, + scaleMaxPrice: max, + } = params; + if (min === undefined || max === undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_REQUIRED); + } + if (count === undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); + } + const prices = normalizeLighterScalePrices( + min, + max, + count, + market.supportedPriceDecimals, + ); + const decimals = market.supportedSizeDecimals; + if ( + !Number.isInteger(decimals) || + decimals < 0 || + decimals > LIGHTER_SCALE_MAX_DECIMALS + ) { + throw new Error('Invalid Lighter Scale precision'); + } + if ( + params.scaleSkew !== undefined && + (!Number.isFinite(params.scaleSkew) || params.scaleSkew <= 0) + ) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + } + let budget: BigNumber | undefined; + let rawSize: BigNumber; + try { + budget = + params.usdAmount === undefined + ? undefined + : parseScaleDecimal(params.usdAmount); + rawSize = budget ? budget.div(prices[0]) : parseScaleDecimal(params.size); + } catch { + throw new Error(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + } + const units = rawSize.shiftedBy(decimals).integerValue(BigNumber.ROUND_DOWN); + if (units.gt(Number.MAX_SAFE_INTEGER) || units.lt(count)) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + } + const split = (total: number): string[] => { + const sizes = splitScaleSizes({ + totalSize: new BigNumber(total).shiftedBy(-decimals).toNumber(), + count, + szDecimals: decimals, + skew: params.scaleSkew, + }); + const sum = sizes.reduce( + (value, size) => value.plus(size), + new BigNumber(0), + ); + if ( + !sum.shiftedBy(decimals).eq(total) || + sizes.some((size) => !new BigNumber(size).shiftedBy(decimals).isInteger()) + ) { + throw new Error('Lighter Scale size conservation failed'); + } + return sizes; + }; + const quote = (sizes: string[]): BigNumber => + sizes.reduce( + (total, size, index) => + total.plus(new BigNumber(size).times(prices[index])), + new BigNumber(0), + ); + let total = units.toNumber(); + if (budget) { + let low = count; + let high = total; + let feasible: number | undefined; + while (low <= high) { + const candidate = low + Math.floor((high - low) / 2); + let sizes: string[]; + try { + sizes = split(candidate); + } catch { + low = candidate + 1; + continue; + } + if (quote(sizes).lte(budget)) { + feasible = candidate; + low = candidate + 1; + } else { + high = candidate - 1; + } + } + if (feasible === undefined) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + } + total = feasible; + } + const sizes = split(total); + const minimumBase = parseScaleDecimal(market.minBaseAmount); + const minimumQuote = parseScaleDecimal(market.minQuoteAmount); + for (const [index, size] of sizes.entries()) { + if (new BigNumber(size).shiftedBy(decimals).gt(LIGHTER_MAX_BASE_AMOUNT)) { + throw new Error('Lighter Scale child exceeds native base amount'); + } + if (new BigNumber(size).lt(minimumBase)) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); + } + if (new BigNumber(size).times(prices[index]).lt(minimumQuote)) { + throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL); + } + } + const normalized = new BigNumber(total).shiftedBy(-decimals); + if (normalized.gt(rawSize) || (budget && quote(sizes).gt(budget))) { + throw new Error('Lighter Scale exposure exceeds intent'); + } + return { + prices, + sizes, + size: normalized.toFixed(), + notional: quote(sizes).toFixed(), + }; +} + +/** Whether every leg is proven unable to execute again. + * @param group - Durable ownership. + * @returns Terminal status. + */ +export function isLighterScaleTerminal(group: LighterScaleGroup): boolean { + return ( + group.placementStopped && + group.rungs.every((rung) => + ['filled', 'canceled', 'rejected', 'prepared'].includes(rung.state), + ) + ); +} + +/** Project actual accepted and submitted exposure without invented identities. + * @param group - Durable ownership. + * @returns Shared receipt plus durable scope. + */ +export function toLighterScaleGroup(group: LighterScaleGroup): ScaleOrderGroup { + const submitted = group.rungs.filter((rung) => rung.state !== 'prepared'); + const uncertain = group.rungs.some( + (rung) => rung.state === 'unknown' || rung.state === 'submitted', + ); + const accepted = group.rungs.filter((rung) => + ['accepted', 'resting', 'filled', 'canceled'].includes(rung.state), + ); + const total = (rungs: LighterScaleRung[]): BigNumber => + rungs.reduce((sum, rung) => sum.plus(rung.size), new BigNumber(0)); + const acceptedSize = total(accepted); + const acceptedChildren: NonNullable = + accepted.map((rung) => { + if ( + rung.orderId !== undefined && + (rung.state === 'resting' || + rung.state === 'filled' || + rung.state === 'canceled') + ) { + return { state: rung.state, orderId: rung.orderId }; + } + return { state: 'waitingForFill' }; + }); + const fillsKnown = + !uncertain && + accepted.length > 0 && + accepted.every((rung) => rung.filledSize !== undefined); + const filledSize = accepted.reduce( + (sum, rung) => + rung.filledSize === undefined ? sum : sum.plus(rung.filledSize), + new BigNumber(0), + ); + let state: ScaleOrderGroup['state'] = 'placing'; + if (group.placementStopped) { + state = 'stopped'; + } + if (uncertain) { + state = 'unknown'; + } + if (isLighterScaleTerminal(group)) { + state = 'terminal'; + } + return { + groupId: group.groupId, + orderId: group.groupId, + symbol: group.symbol, + providerId: 'lighter', + accountIndex: group.accountIndex, + apiKeyIndex: group.apiKeyIndex, + walletAddress: group.walletAddress, + network: group.network, + state, + submittedSize: total(submitted).toFixed(), + ...(uncertain ? {} : { acceptedSize: acceptedSize.toFixed() }), + acceptedChildren, + childOrderIds: accepted.flatMap((rung) => + rung.state === 'resting' && rung.orderId !== undefined + ? [rung.orderId] + : [], + ), + ...(!uncertain && acceptedSize.gt(0) + ? { + weightedAverageLimitPrice: accepted + .reduce( + (sum, rung) => + sum.plus(new BigNumber(rung.size).times(rung.price)), + new BigNumber(0), + ) + .div(acceptedSize) + .toFixed(), + } + : {}), + ...(fillsKnown ? { filledSize: filledSize.toFixed() } : {}), + }; +} + +/** Validate an account journal before using durable financial ownership. + * @param raw - Serialized journal. + * @returns Bounded validated groups. + */ +export function parseLighterScaleGroups( + raw: string | null, +): LighterScaleGroup[] { + if (raw === null) { + return []; + } + if (raw.length > LIGHTER_SCALE_MAX_JOURNAL_LENGTH) { + throw new Error('Lighter Scale journal exceeds its storage bound'); + } + const groups: unknown = JSON.parse(raw); + if (!Array.isArray(groups) || groups.length > LIGHTER_SCALE_MAX_GROUPS) { + throw new Error('Invalid Lighter Scale journal'); + } + const ids = new Set(); + const groupsSeen = new Set(); + const integer = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + for (const value of groups) { + const group = value as Partial; + if ( + group?.version !== 1 || + typeof group.groupId !== 'string' || + !group.groupId.startsWith(LIGHTER_SCALE_PREFIX) || + group.groupId.length > LIGHTER_SCALE_MAX_GROUP_ID_LENGTH || + groupsSeen.has(group.groupId) || + typeof group.symbol !== 'string' || + !group.symbol || + group.symbol.length > LIGHTER_SCALE_MAX_SYMBOL_LENGTH || + !integer(group.accountIndex) || + !integer(group.apiKeyIndex) || + group.apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + group.apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !integer(group.marketId) || + !integer(group.createdAt) || + typeof group.walletAddress !== 'string' || + !/^0x[0-9a-f]{40}$/u.test(group.walletAddress) || + !['mainnet', 'testnet'].includes(group.network ?? '') || + !integer(group.sizeDecimals) || + group.sizeDecimals > LIGHTER_SCALE_MAX_DECIMALS || + !integer(group.priceDecimals) || + group.priceDecimals > LIGHTER_SCALE_MAX_DECIMALS || + typeof group.isBuy !== 'boolean' || + typeof group.reduceOnly !== 'boolean' || + typeof group.placementStopped !== 'boolean' || + !Array.isArray(group.rungs) || + group.rungs.length < SCALE_ORDER_COUNT.min || + group.rungs.length > SCALE_ORDER_COUNT.max + ) { + throw new Error('Invalid Lighter Scale group'); + } + groupsSeen.add(group.groupId); + let previousPrice = 0; + const orderIds = new Set(); + for (const rung of group.rungs) { + if ( + !rung || + !integer(rung.clientOrderId) || + rung.clientOrderId === 0 || + rung.clientOrderId >= LIGHTER_SCALE_CLIENT_ID_LIMIT || + ids.has(rung.clientOrderId) || + !integer(rung.priceInt) || + rung.priceInt < 1 || + rung.priceInt > LIGHTER_MAX_WIRE_PRICE || + !integer(rung.sizeInt) || + rung.sizeInt < 1 || + new BigNumber(rung.sizeInt).gt(LIGHTER_MAX_BASE_AMOUNT) || + ![ + 'prepared', + 'unknown', + 'submitted', + 'accepted', + 'resting', + 'filled', + 'canceled', + 'rejected', + ].includes(rung.state) || + !(rung.nonce === null || integer(rung.nonce)) || + !( + rung.txHash === null || + (typeof rung.txHash === 'string' && + /^[a-f\d]+$/iu.test(rung.txHash) && + rung.txHash.length <= LIGHTER_SCALE_MAX_HASH_LENGTH) + ) || + !( + rung.expiresAt === null || + (integer(rung.expiresAt) && rung.expiresAt > 0) + ) || + (rung.orderId !== undefined && !/^\d{1,20}$/u.test(rung.orderId)) + ) { + throw new Error('Invalid Lighter Scale rung'); + } + if ( + rung.cancelAttempt !== undefined && + (rung.cancelAttempt === null || + typeof rung.cancelAttempt !== 'object' || + rung.orderId === undefined || + !group.placementStopped || + (rung.cancelAttempt.acknowledged !== undefined && + typeof rung.cancelAttempt.acknowledged !== 'boolean') || + !integer(rung.cancelAttempt.apiKeyIndex) || + rung.cancelAttempt.apiKeyIndex < LIGHTER_MIN_TRADING_API_KEY_INDEX || + rung.cancelAttempt.apiKeyIndex > LIGHTER_MAX_TRADING_API_KEY_INDEX || + !integer(rung.cancelAttempt.nonce) || + typeof rung.cancelAttempt.txHash !== 'string' || + !/^[a-f\d]{8,128}$/u.test(rung.cancelAttempt.txHash) || + !integer(rung.cancelAttempt.expiresAt) || + rung.cancelAttempt.expiresAt === 0) + ) { + throw new Error('Invalid Lighter Scale cancellation attempt'); + } + if ( + !parseScaleDecimal(rung.price) + .shiftedBy(group.priceDecimals) + .eq(rung.priceInt) || + !parseScaleDecimal(rung.size) + .shiftedBy(group.sizeDecimals) + .eq(rung.sizeInt) + ) { + throw new Error('Lighter Scale wire quantities differ from intent'); + } + if ( + rung.priceInt <= previousPrice || + (rung.orderId !== undefined && orderIds.has(rung.orderId)) + ) { + throw new Error( + 'Invalid Lighter Scale ladder ordering or duplicate venue identity', + ); + } + previousPrice = rung.priceInt; + if (rung.orderId !== undefined) { + orderIds.add(rung.orderId); + } + ids.add(rung.clientOrderId); + if ( + !( + rung.orderExpiry === null || + (integer(rung.orderExpiry) && rung.orderExpiry > 0) + ) || + (rung.state !== 'prepared' && rung.orderExpiry === null) + ) { + throw new Error('Invalid Lighter Scale order expiry'); + } + if ( + rung.nonAcceptance !== undefined && + (!['failed', 'expired', 'nonce-consumed'].includes( + rung.nonAcceptance, + ) || + !['unknown', 'rejected'].includes(rung.state) || + rung.orderId !== undefined) + ) { + throw new Error('Invalid Lighter Scale non-acceptance proof'); + } + if ( + rung.state !== 'prepared' && + (rung.nonce === null || rung.txHash === null || rung.expiresAt === null) + ) { + throw new Error('Invalid Lighter Scale dispatch identity'); + } + if ( + (rung.state === 'resting' || + rung.state === 'filled' || + rung.state === 'canceled') && + rung.orderId === undefined + ) { + throw new Error('Missing Lighter Scale venue identity'); + } + if ( + rung.filledSize !== undefined && + (typeof rung.filledSize !== 'string' || + !/^\d+(?:\.\d+)?$/u.test(rung.filledSize) || + new BigNumber(rung.filledSize).gt(rung.size)) + ) { + throw new Error('Invalid Lighter Scale fill'); + } + } + } + return groups as LighterScaleGroup[]; +} diff --git a/packages/perps-controller/src/utils/lighterTwap.ts b/packages/perps-controller/src/utils/lighterTwap.ts new file mode 100644 index 00000000000..0234b08169d --- /dev/null +++ b/packages/perps-controller/src/utils/lighterTwap.ts @@ -0,0 +1,128 @@ +import { BigNumber } from 'bignumber.js'; + +import { + LIGHTER_MAX_ORDER_PRICE, + LIGHTER_MAX_BASE_AMOUNT, + LIGHTER_MINUTE_MS, + LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME, +} from '../constants/lighterConfig.js'; + +const MAX_ORDER_PRICE = new BigNumber(LIGHTER_MAX_ORDER_PRICE); +const MAX_BASE_AMOUNT = new BigNumber(LIGHTER_MAX_BASE_AMOUNT); +const MINUTE_MILLISECONDS = LIGHTER_MINUTE_MS; + +/** Inputs for native TWAP wire preparation; this does not authorize dispatch. */ +type LighterTwapWireIntent = { + size: string; + referencePrice: string; + sizeDecimals: number; + priceDecimals: number; + isBuy: boolean; + /** Fractional slippage, represented as an exact decimal. */ + slippage: string; + durationMinutes: number; + /** Millisecond clock reading captured by the caller, never venue seconds. */ + nowMilliseconds: number; + randomize: boolean; + reduceOnly: boolean; +}; + +type LighterTwapWireOrder = Readonly<{ + baseAmount: string; + price: string; + isAsk: 0 | 1; + orderType: 6; + timeInForce: typeof LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME; + reduceOnly: 0 | 1; + triggerPrice: '0'; + orderExpiry: number; +}>; + +/** + * Prepare the verified type6 CreateOrder fields without signing or dispatching. + * + * This primitive deliberately supplies no lifecycle or acceptance claim. Lighter + * TWAP remains unavailable until authoritative parent fills and termination are + * established. The uint32 price and 48-bit amount bounds come from the pinned Lighter + * Go constants. Schedule expiry is a safe JavaScript integer within int64; + * transaction ExpiredAt has a separate bound. Numeric narrowing is not validation. + * No unverified venue duration minimum or maximum is imposed here. + * + * @param intent - Exact amount, market precision, price protection, and clock. + * @returns Immutable native order fields suitable for later lifecycle wiring. + */ +export function prepareLighterTwapOrder( + intent: LighterTwapWireIntent, +): LighterTwapWireOrder { + if (typeof intent.randomize !== 'boolean' || intent.randomize) { + throw new Error('Lighter native TWAP randomization is unsupported'); + } + if ( + typeof intent.isBuy !== 'boolean' || + typeof intent.reduceOnly !== 'boolean' + ) { + throw new Error('Lighter native TWAP requires explicit boolean flags'); + } + for (const precision of [intent.sizeDecimals, intent.priceDecimals]) { + if (!Number.isInteger(precision) || precision < 0 || precision > 255) { + throw new Error('Invalid Lighter native TWAP market precision'); + } + } + if ( + !Number.isSafeInteger(intent.durationMinutes) || + intent.durationMinutes <= 0 || + !Number.isSafeInteger(intent.nowMilliseconds) || + intent.nowMilliseconds <= 0 + ) { + throw new Error('Invalid Lighter native TWAP duration or clock'); + } + const orderExpiry = + intent.nowMilliseconds + intent.durationMinutes * MINUTE_MILLISECONDS; + if ( + !Number.isSafeInteger(orderExpiry) || + orderExpiry <= intent.nowMilliseconds + ) { + throw new Error('Invalid Lighter native TWAP future millisecond expiry'); + } + const values = [intent.size, intent.referencePrice, intent.slippage]; + if ( + values.some( + (value) => typeof value !== 'string' || !/^\d+(?:\.\d+)?$/u.test(value), + ) + ) { + throw new Error('Invalid Lighter native TWAP decimal'); + } + const size = new BigNumber(intent.size).shiftedBy(intent.sizeDecimals); + const reference = new BigNumber(intent.referencePrice); + const slippage = new BigNumber(intent.slippage); + if ( + !size.isInteger() || + !size.isPositive() || + size.isZero() || + size.gt(MAX_BASE_AMOUNT) || + !reference.isPositive() || + reference.isZero() || + !slippage.isFinite() || + slippage.lt(0) || + slippage.gte(1) + ) { + throw new Error('Invalid Lighter native TWAP amount or price protection'); + } + const price = reference + .times(new BigNumber(1).plus(intent.isBuy ? slippage : slippage.negated())) + .shiftedBy(intent.priceDecimals) + .integerValue(intent.isBuy ? BigNumber.ROUND_FLOOR : BigNumber.ROUND_CEIL); + if (!price.isFinite() || price.lt(1) || price.gt(MAX_ORDER_PRICE)) { + throw new Error('Invalid Lighter native TWAP uint32 price bound'); + } + return Object.freeze({ + baseAmount: size.toFixed(0), + price: price.toFixed(0), + isAsk: intent.isBuy ? 0 : 1, + orderType: 6, + timeInForce: LIGHTER_TIME_IN_FORCE_GOOD_TILL_TIME, + reduceOnly: intent.reduceOnly ? 1 : 0, + triggerPrice: '0', + orderExpiry, + }); +} diff --git a/packages/perps-controller/src/utils/lighterTwapReconciliation.ts b/packages/perps-controller/src/utils/lighterTwapReconciliation.ts new file mode 100644 index 00000000000..40a92002c21 --- /dev/null +++ b/packages/perps-controller/src/utils/lighterTwapReconciliation.ts @@ -0,0 +1,323 @@ +import { BigNumber } from 'bignumber.js'; + +import { + LIGHTER_MINUTE_MS, + LIGHTER_TX_EXPIRY_SLACK_MS, +} from '../constants/lighterConfig.js'; +import type { LighterTwapRecord } from '../services/LighterTwapService.js'; +import type { + TwapOrder, + TwapOrderFill, + TwapOrderStatus, +} from '../types/index.js'; +import type { + LighterApiOrder, + LighterRestTrade, +} from '../types/lighter-types.js'; +import { adaptFillFromLighterTrade } from './lighterAdapter.js'; + +export type LighterTwapObservation = { + clientOrderId: string; + parentOrderId: string; + parent: LighterApiOrder; + children: LighterApiOrder[]; + trades: LighterRestTrade[]; + order: TwapOrder; + /** Observation only until venue mapping and cancellation-race proof pass. */ + terminalObserved: boolean; +}; + +/** + * @param numeric - JSON numeric ID, accepted only when lossless. + * @param exact - Optional exact string ID. + * @returns A canonical exact ID. + */ +function exactId(numeric: number, exact?: string): string { + if ( + !Number.isSafeInteger(numeric) || + numeric < 0 || + (exact !== undefined && exact !== String(numeric)) + ) { + throw new Error('Lighter TWAP order identity is unsafe or inconsistent'); + } + return String(numeric); +} + +/** + * @param value - Required venue decimal amount. + * @returns A validated nonnegative exact decimal. + */ +function amount(value: string | undefined): BigNumber { + if (typeof value !== 'string' || !/^\d+(?:\.\d+)?$/u.test(value)) { + throw new Error('Lighter TWAP execution amount is missing or malformed'); + } + return new BigNumber(value); +} + +/** + * Validate an exact parent independently of mutable fill reconciliation. + * + * @param record - Persisted signed schedule ownership. + * @param parent - Exact client-ID lookup row. + * @returns The immutable venue parent ID eligible for cancellation. + */ +export function identifyLighterTwapParent( + record: LighterTwapRecord, + parent: LighterApiOrder, +): string { + const { intent } = record; + if ( + exactId(parent.clientOrderIndex, parent.clientOrderId) !== + intent.clientOrderId + ) { + throw new Error( + 'Lighter TWAP parent does not match immutable signed intent', + ); + } + const parentOrderId = exactId(parent.orderIndex, parent.orderId); + if ( + !Number.isSafeInteger(record.placement.nonce) || + parent.type !== 'twap' || + parent.ownerAccountIndex !== intent.owner.accountIndex || + parent.marketIndex !== intent.marketId || + parent.isAsk === intent.isBuy || + Boolean(parent.reduceOnly) !== intent.reduceOnly || + (parent.reduceOnly !== true && + parent.reduceOnly !== false && + parent.reduceOnly !== 0 && + parent.reduceOnly !== 1) || + parent.timeInForce !== 'good-till-time' || + parent.orderExpiry !== intent.orderExpiry || + parent.nonce !== record.placement.nonce || + !amount(parent.initialBaseAmount).eq(intent.size) || + !amount(parent.price).eq(intent.price) || + (record.parentOrderId !== undefined && + record.parentOrderId !== parentOrderId) + ) { + throw new Error( + 'Lighter TWAP parent does not match immutable signed intent', + ); + } + return parentOrderId; +} + +/** + * Build an observational snapshot from complete parent/child and trade reads. + * Callers must exhaust pagination and fence concurrent parent changes first. + * Terminal observations are not authorization to clear durable obligations; + * rollout must establish venue status and cancel-race semantics separately. + * + * @param record - Immutable owned schedule and signed placement identity. + * @param rows - Complete relevant active/inactive order rows. + * @param trades - Complete unaggregated trades for exact child IDs. + * @param observedAt - Millisecond collection time. + * @returns Reconciled totals, exact fills and observed native parent state. + */ +export function reconcileLighterTwapObservation( + record: LighterTwapRecord, + rows: LighterApiOrder[], + trades: LighterRestTrade[], + observedAt: number, +): LighterTwapObservation { + const { intent } = record; + const parents = rows.filter( + (row) => + exactId(row.clientOrderIndex, row.clientOrderId) === intent.clientOrderId, + ); + if (parents.length !== 1) { + throw new Error('Lighter TWAP exact parent is missing or ambiguous'); + } + const parent = parents[0]; + const parentOrderId = identifyLighterTwapParent(record, parent); + const children: LighterApiOrder[] = []; + const childIds = new Set(); + for (const row of rows) { + if (row === parent) { + continue; + } + const stringLink = row.parentOrderId; + const numericLink = row.parentOrderIndex; + const linked = + stringLink === parentOrderId || numericLink === parent.orderIndex; + if (!linked) { + continue; + } + if ( + stringLink !== parentOrderId || + numericLink !== parent.orderIndex || + row.type !== 'twap-sub' || + row.ownerAccountIndex !== intent.owner.accountIndex || + row.marketIndex !== intent.marketId || + row.isAsk === intent.isBuy + ) { + throw new Error( + 'Lighter TWAP child parent linkage or ownership mismatch', + ); + } + const id = exactId(row.orderIndex, row.orderId); + if (childIds.has(id)) { + throw new Error('Lighter TWAP child identity is ambiguous'); + } + childIds.add(id); + children.push(row); + } + const uniqueTrades = new Map(); + const fills: TwapOrderFill[] = []; + const totals = new Map(); + for (const trade of trades) { + exactId(trade.bidId, trade.bidIdStr); + exactId(trade.askId, trade.askIdStr); + const sideId = intent.isBuy ? trade.bidId : trade.askId; + const sideOwner = intent.isBuy ? trade.bidAccountId : trade.askAccountId; + if ( + !childIds.has(exactId(sideId)) || + sideOwner !== intent.owner.accountIndex || + trade.marketId !== intent.marketId + ) { + throw new Error('Lighter TWAP trade is not owned by an exact child'); + } + const id = exactId(trade.tradeId, trade.tradeIdStr); + const previous = uniqueTrades.get(id); + if (previous) { + if (JSON.stringify(previous) !== JSON.stringify(trade)) { + throw new Error('Lighter TWAP conflicting duplicate trade'); + } + continue; + } + if ( + !trade.txHash || + !Number.isSafeInteger(trade.timestamp) || + trade.timestamp < intent.startedAt - LIGHTER_TX_EXPIRY_SLACK_MS + ) { + throw new Error( + 'Lighter TWAP trade provenance is missing or predates intent', + ); + } + uniqueTrades.set(id, trade); + const adapted = adaptFillFromLighterTrade( + trade, + intent.symbol, + intent.owner.accountIndex, + ); + const fill: TwapOrderFill = { + fillId: id, + orderId: String(sideId), + side: intent.isBuy ? 'buy' : 'sell', + price: trade.price, + size: trade.size, + fee: adapted.fee, + feeToken: adapted.feeToken, + timestamp: trade.timestamp, + transactionHash: trade.txHash, + }; + fills.push(fill); + const sum = totals.get(String(sideId)) ?? { + base: new BigNumber(0), + quote: new BigNumber(0), + }; + sum.base = sum.base.plus(amount(trade.size)); + sum.quote = sum.quote.plus(amount(trade.usdAmount)); + totals.set(String(sideId), sum); + } + let executed = new BigNumber(0); + let notional = new BigNumber(0); + for (const child of children) { + const sum = totals.get(String(child.orderIndex)) ?? { + base: new BigNumber(0), + quote: new BigNumber(0), + }; + if ( + !amount(child.filledBaseAmount).eq(sum.base) || + !amount(child.filledQuoteAmount).eq(sum.quote) + ) { + throw new Error( + 'Lighter TWAP child fill totals do not match complete trade history', + ); + } + executed = executed.plus(sum.base); + notional = notional.plus(sum.quote); + } + if ( + !amount(parent.filledBaseAmount).eq(executed) || + !amount(parent.filledQuoteAmount).eq(notional) || + executed.gt(intent.size) + ) { + throw new Error( + 'Lighter TWAP parent fill totals do not match complete child history', + ); + } + let status: TwapOrderStatus; + if (['in-progress', 'pending', 'open'].includes(parent.status)) { + status = 'active'; + } else if (parent.status === 'filled') { + if (!executed.eq(intent.size)) { + throw new Error('Lighter TWAP filled parent is underfilled'); + } + status = 'completed'; + } else if (parent.status === 'canceled-expired') { + status = executed.eq(intent.size) ? 'completed' : 'completed_underfilled'; + } else if (parent.status === 'canceled') { + status = 'canceled'; + } else { + throw new Error( + `Lighter TWAP parent status mapping is unverified: ${parent.status}`, + ); + } + if ( + status !== 'active' && + children.some((child) => + ['in-progress', 'pending', 'open'].includes(child.status), + ) + ) { + throw new Error( + 'Lighter TWAP terminal parent still has active child orders', + ); + } + const elapsed = Math.max( + 0, + Math.min(observedAt, intent.orderExpiry) - intent.startedAt, + ); + const order: TwapOrder = { + orderId: parentOrderId, + symbol: intent.symbol, + side: intent.isBuy ? 'buy' : 'sell', + size: intent.size, + executedSize: executed.toFixed(), + remainingSize: new BigNumber(intent.size).minus(executed).toFixed(), + executedNotional: notional.toFixed(), + ...(executed.gt(0) + ? { averagePrice: notional.div(executed).toFixed() } + : {}), + fillProgressBps: executed + .div(intent.size) + .times(10_000) + .integerValue(BigNumber.ROUND_FLOOR) + .toNumber(), + timeProgressBps: Math.min( + 10_000, + Math.floor( + (elapsed / (intent.durationMinutes * LIGHTER_MINUTE_MS)) * 10_000, + ), + ), + elapsedTimeMilliseconds: elapsed, + durationMinutes: intent.durationMinutes, + randomize: false, + reduceOnly: intent.reduceOnly, + status, + startedAt: intent.startedAt, + lastUpdated: observedAt, + fills: fills.sort( + (a, b) => a.timestamp - b.timestamp || a.fillId.localeCompare(b.fillId), + ), + providerId: 'lighter', + }; + return { + clientOrderId: intent.clientOrderId, + parentOrderId, + parent, + children, + trades: [...uniqueTrades.values()], + order, + terminalObserved: status !== 'active', + }; +} diff --git a/packages/perps-controller/src/utils/positionProtection.ts b/packages/perps-controller/src/utils/positionProtection.ts new file mode 100644 index 00000000000..342ebcc6ad7 --- /dev/null +++ b/packages/perps-controller/src/utils/positionProtection.ts @@ -0,0 +1,40 @@ +import { BigNumber } from 'bignumber.js'; + +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { UpdatePositionTPSLParams } from '../types/index.js'; + +/** + * Reject a protection mutation whose caller-owned position no longer matches. + * + * @param expected - Optional immutable caller precondition. + * @param actual - Authoritative current position, if any. + */ +export function assertExpectedPosition( + expected: UpdatePositionTPSLParams['expectedPosition'], + actual: { size: string; entryPrice: string } | undefined, +): void { + if (expected === undefined) { + return; + } + const decimal = /^-?(?:\d+(?:\.\d*)?|\.\d+)$/u; + if ( + !actual || + ![expected.size, expected.entryPrice, actual.size, actual.entryPrice].every( + (value) => typeof value === 'string' && decimal.test(value), + ) + ) { + throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + } + const expectedSize = new BigNumber(expected.size); + const expectedEntryPrice = new BigNumber(expected.entryPrice); + if ( + !expectedSize.isFinite() || + expectedSize.isZero() || + !expectedEntryPrice.isFinite() || + !expectedEntryPrice.gt(0) || + !expectedSize.eq(actual.size) || + !expectedEntryPrice.eq(actual.entryPrice) + ) { + throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + } +} diff --git a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts index a48b4fcd2bb..fab5442b520 100644 --- a/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts +++ b/packages/perps-controller/src/utils/subscriptionFeeWaiver.ts @@ -225,11 +225,11 @@ function isFlagSet(flags: number, bit: number): boolean { /** * Whether a cloid carries the subscription program marker in its leading bytes. * - * Only orders that had no cloid of their own get the program marker; an order - * that already carried one (a Scale rung) keeps its own leading marker and - * carries the subscription attribution in the flag byte instead. Decoders - * should therefore key on {@link hasFeeReductionAppliedFlag}, and use this only - * to tell the two layouts apart. + * Orders without their own cloid acquire this marker when a subscription + * discount applies. Position TP/SL also reserves this layout for correlation, + * with a zero flag byte when no discount applies. A Scale rung keeps its own + * leading marker and carries attribution in its flag byte. Marker presence + * alone therefore does not prove a discount; use {@link hasFeeReductionAppliedFlag}. * * @param clientOrderId - A venue client order ID, or nothing. * @returns True when the cloid starts with the subscription program id. diff --git a/packages/perps-controller/tests/capabilities-public-api.test.ts b/packages/perps-controller/tests/capabilities-public-api.test.ts new file mode 100644 index 00000000000..d5660730145 --- /dev/null +++ b/packages/perps-controller/tests/capabilities-public-api.test.ts @@ -0,0 +1,62 @@ +import { HYPERLIQUID_ORDER_CAPABILITIES as subpathCapabilities } from '../src/constants/hyperLiquidConfig.js'; +import { HYPERLIQUID_ORDER_CAPABILITIES } from '../src/constants/index.js'; +import { PerpsController } from '../src/index.js'; +import type { + DirectProviderOrderCapabilities, + PerpsControllerGetOrderCapabilitiesAction, + PerpsOrderCapabilities, +} from '../src/index.js'; + +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +describe('public order capability contract', () => { + it('exports the controller query and its messenger action', () => { + const action: PerpsControllerGetOrderCapabilitiesAction['type'] = + 'PerpsController:getOrderCapabilities'; + + expect(typeof PerpsController.prototype.getOrderCapabilities).toBe( + 'function', + ); + expect(action).toBe('PerpsController:getOrderCapabilities'); + }); + + it('preserves ready results from providers without a trigger declaration', () => { + const legacy: DirectProviderOrderCapabilities = { + status: 'ready', + providerId: 'lighter', + supportedStrategies: [], + }; + const routed: PerpsOrderCapabilities = legacy; + + expect(routed.status).toBe('ready'); + expect(routed.supportedTriggerOrderTypes).toBeUndefined(); + }); + + it('exports the same standalone trigger declaration through both constants entrypoints', () => { + expect(subpathCapabilities).toBe(HYPERLIQUID_ORDER_CAPABILITIES); + expect( + HYPERLIQUID_ORDER_CAPABILITIES.supportedTriggerOrderTypes, + ).toStrictEqual([ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ]); + }); + + it('prevents a consumer from changing the shared trigger declaration', () => { + const declaration = HYPERLIQUID_ORDER_CAPABILITIES; + + expect(Object.isFrozen(declaration)).toBe(true); + expect(Object.isFrozen(declaration.supportedTriggerOrderTypes)).toBe(true); + expect(() => + Array.prototype.push.call( + declaration.supportedTriggerOrderTypes, + 'market', + ), + ).toThrow(TypeError); + expect(declaration.supportedTriggerOrderTypes).toHaveLength(4); + }); +}); diff --git a/packages/perps-controller/tests/public-api-types.ts b/packages/perps-controller/tests/public-api-types.ts new file mode 100644 index 00000000000..8110a551448 --- /dev/null +++ b/packages/perps-controller/tests/public-api-types.ts @@ -0,0 +1,675 @@ +import type { + ChaseOrder, + PerpsController, + GetChaseOrderOwnershipParams, + PerpsChaseOrderOwner, + PerpsChaseOrderChild, + PerpsChaseOrderDispatch, + PerpsChaseOrderOwnership, + PerpsControllerGetChaseOrderOwnershipAction, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, + PerpsControllerReconcileChaseOrderCancellationAction, + OrderResult, + PositionProtectionReceipt, + LighterCredentials, + UpdatePositionTPSLParams, + OrderEditObservation, + EditOrderParams, + LighterSignModifyOrderWireParams, + PerpsControllerEditOrderAction, + PerpsProvider, + DirectProviderOrderCapabilities, + ExpectedScaleLadder, + OrderParams, + ScaleOrderGroup, + ScaleOrderChild, + GetScalePriceLadderParams, + PerpsScalePriceLadder, + PerpsControllerGetScalePriceLadderAction, + PerpsControllerGetScaleOrderGroupsAction, + PerpsControllerReviewScaleOrderGroupsAction, + LighterWasmCall, + AttachedOrderGroup, + PerpsControllerGetAttachedOrderGroupsAction, + PerpsControllerReviewAttachedOrderGroupsAction, + TriggerOrderType, + PerpsRecoveredDispatch, + PerpsPendingManualRecovery, + PerpsRecoveryVenueReview, + PerpsRecoveryProtectionResult, + ResolveRecoveryProtectionParams, + PerpsControllerReviewRecoveryVenueAction, + PerpsControllerResolveRecoveryProtectionAction, + PerpsControllerGetRecoveredDispatchesAction, + PerpsControllerReconcileRecoveredDispatchesAction, + PerpsControllerAcknowledgeRecoveredDispatchAction, +} from '@metamask/perps-controller'; +import type { + LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT, + LIGHTER_NATIVE_PROBE_CANCEL_LIMIT, + LIGHTER_NATIVE_PROBE_MAX_NOTIONAL, + LIGHTER_CHASE_MIN_INTERVAL_MS, + LIGHTER_CHASE_MAX_DURATION_MS, + LIGHTER_CHASE_MAX_REPRICINGS, + LIGHTER_CHASE_MAX_DISTANCE_BPS, + LIGHTER_CHASE_DEFAULT_INTERVAL_MS, + LIGHTER_CHASE_DEFAULT_DURATION_MS, + LIGHTER_CHASE_DEFAULT_REPRICINGS, + LIGHTER_CHASE_DEFAULT_DISTANCE_BPS, +} from '@metamask/perps-controller/constants/lighterConfig'; +import type { + readLighterChaseQuote, + reconcileLighterChaseChild, + identifyLighterChaseChild, +} from '@metamask/perps-controller/utils/lighterChase'; +import type { prepareLighterTwapOrder } from '@metamask/perps-controller/utils/lighterTwap'; +import type { + reconcileLighterTwapObservation, + identifyLighterTwapParent, +} from '@metamask/perps-controller/utils/lighterTwapReconciliation'; + +// Compile-time consumer contracts. These are not runtime fixture assertions. +type AssertCompatible = Actual; +type IsExact = + (() => Value extends Actual ? 1 : 2) extends < + Value, + >() => Value extends Expected ? 1 : 2 + ? true + : false; +type AssertTrue = Value; +export type NativeEditConsumerContracts = [ + AssertTrue< + IsExact, OrderEditObservation> + >, + AssertTrue< + IsExact< + OrderEditObservation['status'], + 'pending' | 'settled' | 'failed' | 'terminal' + > + >, + AssertTrue< + IsExact< + LighterWasmCall<'_signModifyOrder'>['params'], + LighterSignModifyOrderWireParams + > + >, + AssertTrue< + IsExact< + LighterSignModifyOrderWireParams, + [ + accountIndex: number, + marketIndex: number, + orderIndex: string, + baseAmount: number, + price: number, + triggerPrice: number, + nonce: number, + ] + > + >, + AssertTrue< + IsExact< + Parameters, + [params: EditOrderParams] + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, +]; +export type RecoveryConsumerContracts = [ + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + Parameters, + [params: ResolveRecoveryProtectionParams] + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + + AssertTrue< + IsExact< + Parameters, + [] + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + PerpsControllerReconcileRecoveredDispatchesAction['type'], + 'PerpsController:reconcileRecoveredDispatches' + > + >, + AssertTrue< + IsExact + >, + AssertTrue< + IsExact + >, + AssertTrue< + IsExact< + Parameters, + [] + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + Parameters, + [recoveryId: string] + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertCompatible< + PerpsRecoveredDispatch, + { + recoveryId: string; + kind: number; + intent: string; + txHash: null; + outcome: 'unknown'; + evidence: string; + } + >, + AssertCompatible< + PerpsRecoveredDispatch, + { + recoveryId: string; + kind: number; + intent: string; + txHash: null; + outcome: 'unknown'; + evidence: string; + apiKeyIndex: number; + acknowledgeable: false; + } + >, + AssertCompatible< + PerpsControllerGetRecoveredDispatchesAction['handler'], + () => Promise + >, + AssertCompatible< + PerpsControllerAcknowledgeRecoveredDispatchAction['handler'], + (recoveryId: string) => Promise + >, + AssertCompatible< + PerpsControllerGetRecoveredDispatchesAction['type'], + 'PerpsController:getRecoveredDispatches' + >, + AssertCompatible< + PerpsControllerAcknowledgeRecoveredDispatchAction['type'], + 'PerpsController:acknowledgeRecoveredDispatch' + >, +]; + +export type TriggerCapabilityConsumerContract = AssertTrue< + IsExact< + Extract< + DirectProviderOrderCapabilities, + { status: 'ready' } + >['supportedTriggerOrderTypes'], + readonly TriggerOrderType[] | undefined + > +>; + +export type PartialProtectionCapabilityConsumerContract = AssertTrue< + IsExact< + NonNullable< + NonNullable< + Extract< + DirectProviderOrderCapabilities, + { status: 'ready' } + >['positionTpsl'] + >['partialCoverage'] + >['pair'], + 'equal-quantity-oco' | 'independent' + > +>; + +export type PartialRecoveryConsumerContract = AssertTrue< + IsExact< + PerpsPendingManualRecovery['partialIntent'], + | { + version: 1 | 2; + positionSide: 'long' | 'short'; + linkage: 'single' | 'oco' | 'independent'; + legs: { + type: 'take-profit' | 'stop-loss'; + size: string; + clientOrderId: string; + }[]; + } + | undefined + > +>; + +export type RecoveryAccountCapacityConsumerContract = AssertTrue< + IsExact +>; +// Native grouped signing preserves a fixed tuple for every supported shape. +type GroupedCall = LighterWasmCall<'_signCreateGroupedOrders'>; +type WireOrder = [ + number, + number, + string, + string, + number, + number, + number, + number, + string, + number, +]; +type TwoOrderParams = [ + number, + Group, + Count, + ...WireOrder, + ...WireOrder, + number, +]; +type ThreeOrderParams = [ + number, + Group, + Count, + ...WireOrder, + ...WireOrder, + ...WireOrder, + number, +]; +type RejectsParams = Params extends GroupedCall['params'] + ? false + : true; +export type GroupedSigningConsumerContracts = [ + AssertCompatible>, + AssertCompatible>, + AssertCompatible>, + AssertTrue>>, + AssertTrue>>, + AssertTrue>>, + AssertTrue>>, + AssertTrue>>, + AssertTrue>>, + AssertTrue>>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, +]; + +export type AttachedGroupConsumerContracts = [ + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + AttachedOrderGroup['submission'], + 'prepared' | 'unknown' | 'accepted' | 'canceled' | 'completed' + > + >, +]; + +export type AttachedHistoryConsumerContract = AssertTrue< + IsExact< + AttachedOrderGroup['historyStatus'], + 'complete' | 'bounded' | undefined + > +>; + +export type ScaleInventoryConsumerContracts = [ + AssertTrue< + IsExact< + Extract< + keyof ScaleOrderGroup, + 'success' | 'error' | 'partialState' | 'averagePrice' + >, + never + > + >, + AssertTrue< + IsExact>, []> + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + Extract< + ScaleOrderChild, + { state: 'resting' | 'filled' | 'canceled' } + >['orderId'], + string + > + >, + AssertTrue< + IsExact< + ScaleOrderGroup['state'], + 'placing' | 'stopped' | 'unknown' | 'terminal' + > + >, +]; + +export type ScaleSizingConsumerContracts = [ + AssertTrue< + IsExact< + Parameters, + [params: GetScalePriceLadderParams] + > + >, + AssertTrue< + IsExact< + NonNullable, + Readonly< + ( + | { size: string; usdAmount?: never } + | { usdAmount: string; size?: never } + ) & { + skew?: number; + } + > + > + >, + AssertTrue< + IsExact< + Extract['sizingPreview'], + | Readonly<{ + sizes: readonly string[]; + totalSize: string; + totalNotional: string; + minimumBaseSize: string; + minimumQuoteAmount: string; + sizeDecimals: number; + }> + | undefined + > + >, + AssertTrue< + IsExact< + { size: string; usdAmount: string } extends NonNullable< + GetScalePriceLadderParams['sizing'] + > + ? true + : false, + false + > + >, + AssertTrue< + IsExact< + Record extends NonNullable< + GetScalePriceLadderParams['sizing'] + > + ? true + : false, + false + > + >, +]; + +export type LighterTwapWireConsumerContract = AssertCompatible< + { + readonly baseAmount: string; + readonly price: string; + readonly orderExpiry: number; + }, + ReturnType +>; + +export type LighterChaseConsumerContracts = [ + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue>, + AssertTrue< + IsExact< + Awaited>>, + ChaseOrder[] + > + >, + AssertTrue< + IsExact< + Awaited>>, + ChaseOrder[] + > + >, + AssertTrue, string>>, +]; + +export type LighterTwapObservationConsumerContracts = [ + AssertTrue, string>>, + AssertTrue< + IsExact< + Parameters< + typeof reconcileLighterTwapObservation + >[0]['intent']['clientOrderId'], + string + > + >, + AssertTrue< + IsExact< + ReturnType['terminalObserved'], + boolean + > + >, +]; + +export type LighterChaseIdentityConsumerContracts = [ + AssertTrue, string>>, + AssertTrue< + IsExact['filledSize'], string> + >, +]; +export type ExpectedScalePreviewConsumer = AssertTrue< + IsExact +>; +export type PreviewFieldsSatisfyPlacement = AssertCompatible< + ExpectedScaleLadder, + { prices: readonly string[] } & NonNullable< + Extract['sizingPreview'] + > +>; + +export type ChaseOwnershipConsumerContracts = [ + AssertTrue< + IsExact< + Parameters, + [input: GetChaseOrderOwnershipParams] + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + PerpsControllerGetChaseOrderOwnershipAction['type'], + 'PerpsController:getChaseOrderOwnership' + > + >, + AssertTrue< + IsExact< + ReturnType, + Promise + > + >, + AssertTrue< + IsExact< + ReturnType>, + Promise + > + >, + AssertTrue< + IsExact< + Extract['children'], + PerpsChaseOrderChild[] + > + >, + AssertTrue< + IsExact< + Extract['owner'], + PerpsChaseOrderOwner + > + >, + AssertTrue< + IsExact + >, + AssertTrue>, + AssertTrue< + IsExact['orderId'], string> + >, + AssertTrue< + IsExact< + NonNullable['filledSize'], + string + > + >, + AssertTrue< + IsExact< + PerpsChaseOrderOwnership['status'], + 'available' | 'unavailable' | 'unsupported' + > + >, +]; + +export type ChaseCancellationReconciliationConsumerContracts = [ + AssertTrue< + IsExact< + Parameters< + PerpsControllerReconcileChaseOrderCancellationAction['handler'] + >, + [input: ReconcileChaseOrderCancellationParams] + > + >, + AssertTrue< + IsExact< + ReturnType< + PerpsControllerReconcileChaseOrderCancellationAction['handler'] + >, + Promise + > + >, + AssertTrue< + IsExact< + PerpsControllerReconcileChaseOrderCancellationAction['type'], + 'PerpsController:reconcileChaseOrderCancellation' + > + >, + AssertTrue< + IsExact< + Parameters, + [input: ReconcileChaseOrderCancellationParams] + > + >, + AssertTrue< + IsExact< + ReturnType>, + Promise + > + >, + AssertTrue< + IsExact< + ReconcileChaseOrderCancellationParams['owner'], + PerpsChaseOrderOwner + > + >, + AssertTrue< + IsExact< + ReconcileChaseOrderCancellationParams['cancellation'], + { nonce: number; txHash: string; expiresAt: number } + > + >, + AssertTrue< + IsExact< + ReconcileChaseOrderCancellationResult['status'], + 'settled' | 'unresolved' | 'unsupported' + > + >, + AssertTrue< + IsExact< + Exclude< + ReconcileChaseOrderCancellationResult, + { status: 'unsupported' } + >['order'], + ChaseOrder + > + >, +]; + +export type IndependentProtectionPublicContract = [ + AssertTrue< + IsExact< + UpdatePositionTPSLParams['partialPairLinkage'], + 'equal-quantity-oco' | 'independent' | undefined + > + >, + AssertTrue< + IsExact< + NonNullable, + PositionProtectionReceipt + > + >, + AssertTrue< + IsExact< + PositionProtectionReceipt['legs'][number]['requestedSize'], + string | undefined + > + >, +]; + +export type ChaseProbePublicContract = AssertTrue< + IsExact +>; diff --git a/packages/perps-controller/tests/public-api.test.ts b/packages/perps-controller/tests/public-api.test.ts index 944219f21b9..d9ad4e4c3c7 100644 --- a/packages/perps-controller/tests/public-api.test.ts +++ b/packages/perps-controller/tests/public-api.test.ts @@ -1,3 +1,18 @@ +import { + LIGHTER_MAX_WIRE_PRICE, + LIGHTER_GROUPING_ONE_TRIGGERS_THE_OTHER, + LIGHTER_GROUPING_ONE_TRIGGERS_OCO, + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_TRADING_API_KEY_INDEX, + LIGHTER_TRADING_API_KEY_COUNT, + LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS, + LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS, + LIGHTER_KEY_REGISTRATION_VISIBILITY_MAX_ATTEMPTS, + LIGHTER_FILL_REPLAY_LIMIT, + LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT, + LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT, + LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT, +} from '../src/constants/index.js'; // Checks the account-signer and agent surface through the package entrypoint, // the way a client imports it, so a dropped or renamed export fails here. import { @@ -6,22 +21,145 @@ import { PerpsController, } from '../src/index.js'; import type { + DirectProviderOrderCapabilities, + DirectProviderOrderCapabilitiesUnavailableReason, + UpdatePositionTPSLParams, + MarketInfo, PerpsAccountSigner, PerpsAgentAccount, PerpsAgentSigner, PerpsControllerClearAgentSignersAction, + PerpsControllerReconcileChaseOrderCancellationAction, PerpsControllerPrepareTradingWalletAction, PerpsControllerSetAgentSignerAction, PerpsTypedDataPayload, } from '../src/index.js'; - -// The SDK ships ES modules only, which Jest cannot load below Node 24.9; the -// entrypoint only needs its error class to be defined. -jest.mock('@nktkas/hyperliquid', () => ({ - HyperliquidError: class MockHyperliquidError extends Error {}, -})); +import { createGuardedHyperLiquidClient } from '../src/utils/guardedHyperLiquidClient.js'; +import { + LIGHTER_ATTACHED_MAX_GROUPS, + LIGHTER_ATTACHED_HANDLE_PREFIX, + parseLighterAttachedGroups, + correlateLighterAttachedOrders, + toAttachedOrderGroup, +} from '../src/utils/lighterAttachedOrders.js'; +import { assertExpectedPosition } from '../src/utils/positionProtection.js'; describe('@metamask/perps-controller public API', () => { + it('exports the durable recovery account capacity', () => { + expect(LIGHTER_RECOVERY_ACCOUNT_INDEX_LIMIT).toBe(64); + }); + it('exports attached constants and utility subpath helpers', () => { + expect([ + LIGHTER_MAX_WIRE_PRICE, + LIGHTER_GROUPING_ONE_TRIGGERS_THE_OTHER, + LIGHTER_GROUPING_ONE_TRIGGERS_OCO, + ]).toStrictEqual([4294967295, 1, 3]); + expect(LIGHTER_ATTACHED_MAX_GROUPS).toBe(64); + expect(LIGHTER_ATTACHED_HANDLE_PREFIX).toBe('lighter-attached:'); + expect(parseLighterAttachedGroups(null)).toStrictEqual([]); + expect(typeof correlateLighterAttachedOrders).toBe('function'); + expect(typeof toAttachedOrderGroup).toBe('function'); + }); + it('exports durable attached-order inventory and explicit venue review', () => { + expect(typeof PerpsController.prototype.getAttachedOrderGroups).toBe( + 'function', + ); + expect(typeof PerpsController.prototype.reviewAttachedOrderGroups).toBe( + 'function', + ); + }); + it('exports strict recovery review and explicit protection resolution', () => { + expect(typeof PerpsController.prototype.reviewRecoveryVenue).toBe( + 'function', + ); + expect(typeof PerpsController.prototype.resolveRecoveryProtection).toBe( + 'function', + ); + }); + + it('exposes explicit non-financial reconciliation on the public controller', () => { + expect(typeof PerpsController.prototype.reconcileRecoveredDispatches).toBe( + 'function', + ); + }); + + it('exports observation-only exact Chase cancellation reconciliation and its messenger action', () => { + const action: PerpsControllerReconcileChaseOrderCancellationAction['type'] = + 'PerpsController:reconcileChaseOrderCancellation'; + expect( + typeof PerpsController.prototype.reconcileChaseOrderCancellation, + ).toBe('function'); + expect(action).toBe('PerpsController:reconcileChaseOrderCancellation'); + }); + + it('exports the trading configuration constants', () => { + expect([ + LIGHTER_MIN_TRADING_API_KEY_INDEX, + LIGHTER_MAX_TRADING_API_KEY_INDEX, + LIGHTER_TRADING_API_KEY_COUNT, + LIGHTER_KEY_REGISTRATION_VISIBILITY_TIMEOUT_MS, + LIGHTER_KEY_REGISTRATION_VISIBILITY_POLL_MS, + LIGHTER_KEY_REGISTRATION_VISIBILITY_MAX_ATTEMPTS, + LIGHTER_FILL_REPLAY_LIMIT, + ]).toStrictEqual([2, 254, 253, 10000, 250, 40, 100]); + }); + + it('exposes optional protection preconditions, capabilities and utility subpath helpers', () => { + const legacy: UpdatePositionTPSLParams = { symbol: 'BTC' }; + const guarded: UpdatePositionTPSLParams = { + symbol: 'BTC', + expectedPosition: { size: '-1', entryPrice: '100000' }, + }; + const capability: DirectProviderOrderCapabilities = { + status: 'ready', + providerId: 'lighter', + supportedStrategies: [], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_market', + stopLossOrderType: 'stop_market', + defaultCoverage: 'position-snapshot', + partialCoverage: { + single: true, + pair: 'equal-quantity-oco', + replacement: 'cancel-before-create', + recovery: 'explicit-current-position-intent', + }, + }, + }; + expect(legacy.expectedPosition).toBeUndefined(); + expect(() => + assertExpectedPosition(guarded.expectedPosition, { + size: '-1.0', + entryPrice: '100000.0', + }), + ).not.toThrow(); + expect(capability.positionTpsl?.supportsExpectedPosition).toBe(true); + expect(capability.positionTpsl?.partialCoverage?.pair).toBe( + 'equal-quantity-oco', + ); + expect(typeof createGuardedHyperLiquidClient).toBe('function'); + }); + + it('exports the native trigger-limit wire types', () => { + expect([ + LIGHTER_ORDER_TYPE_STOP_LOSS_LIMIT, + LIGHTER_ORDER_TYPE_TAKE_PROFIT_LIMIT, + ]).toStrictEqual([3, 5]); + }); + + it('exports optional native price precision and unsupported-market capability reasons', () => { + const unspecifiedGrid: Pick = {}; + const venueGrid: Pick = { priceDecimals: 2 }; + const reason: DirectProviderOrderCapabilitiesUnavailableReason = + 'order_market_unsupported'; + + expect(unspecifiedGrid.priceDecimals).toBeUndefined(); + expect(venueGrid.priceDecimals).toBe(2); + expect(reason).toBe('order_market_unsupported'); + }); + it('exports the EIP-712 shape of a HyperLiquid L1 action', () => { expect(HYPERLIQUID_L1_ACTION_DOMAIN_NAME).toBe('Exchange'); expect(HYPERLIQUID_L1_ACTION_PRIMARY_TYPE).toBe('Agent'); diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 0abfe6a7ece..5c5439e447c 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -8,6 +8,8 @@ import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; import { + createDeferred, + createMockEvmAccount, createMockInfrastructure, createMockMessenger, } from '../helpers/serviceMocks.js'; @@ -29,12 +31,18 @@ import { AggregatedPerpsProvider } from '../../src/providers/AggregatedPerpsProv import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; import { RewardsIntegrationService } from '../../src/services/RewardsIntegrationService.js'; import type { + OrderParams, GetAvailableDexsParams, + GetScalePriceLadderParams, PerpsProvider, PerpsPlatformDependencies, PerpsProviderType, } from '../../src/types/index.js'; -import { PerpsAnalyticsEvent } from '../../src/types/index.js'; +import { + PerpsAnalyticsEvent, + PerpsTraceNames, + PerpsTraceOperations, +} from '../../src/types/index.js'; import { STRATEGY_ORDER_TYPES } from '../../src/utils/orderTypes.js'; jest.mock('../../src/providers/HyperLiquidProvider'); @@ -53,6 +61,7 @@ jest.mock( jest.mock('../../src/utils/wait', () => ({ wait: jest.fn().mockResolvedValue(undefined), })); +import { wait as mockWait } from '../../src/utils/wait'; // Mock stream manager const mockStreamManager = { @@ -1453,6 +1462,86 @@ describe('PerpsController', () => { }); }); + describe('Scale submission snapshots', () => { + it.each(['placeOrder', 'validateOrder'] as const)( + 'returns a malformed preview refusal from %s without readiness', + async (method) => { + const result = await controller[method]({ + symbol: 'BTC', + orderType: 'scale', + isBuy: true, + size: '2', + expectedScaleLadder: {} as OrderParams['expectedScaleLadder'], + }); + expect(result).toEqual({ + [method === 'placeOrder' ? 'success' : 'isValid']: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + expect( + mockMarketDataServiceInstance.validateOrder, + ).not.toHaveBeenCalled(); + }, + ); + + it.each(['placeOrder', 'validateOrder'] as const)( + 'owns %s intent during readiness despite mutation, replacement and deletion', + async (method) => { + const release = createDeferred(); + const started = createDeferred(); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => { + throw new Error('Transient initialization failure'); + }); + jest.mocked(mockWait).mockImplementationOnce(() => { + started.resolve(); + return release.promise; + }); + const initialization = controller.init(); + await started.promise; + const expectation = { + prices: ['100', '200'], + sizes: ['1', '1'], + totalSize: '2', + totalNotional: '300', + minimumBaseSize: '0.1', + minimumQuoteAmount: '1', + sizeDecimals: 1, + }; + const request: OrderParams = { + symbol: 'BTC', + orderType: 'scale', + isBuy: true, + size: '2', + providerId: 'hyperliquid', + expectedScaleLadder: expectation, + }; + const original = JSON.parse(JSON.stringify(request)) as OrderParams; + mockTradingServiceInstance.placeOrder.mockResolvedValue({ + success: true, + }); + mockMarketDataServiceInstance.validateOrder.mockResolvedValue({ + isValid: true, + }); + const pending = controller[method](request); + request.size = '200'; + expectation.prices[0] = '1'; + expectation.sizes[0] = '100'; + request.expectedScaleLadder = { ...expectation, totalSize: '200' }; + delete request.expectedScaleLadder; + release.resolve(); + await initialization; + await pending; + const service = + method === 'placeOrder' + ? mockTradingServiceInstance.placeOrder + : mockMarketDataServiceInstance.validateOrder; + expect(service).toHaveBeenCalledWith( + expect.objectContaining({ params: original }), + ); + }, + ); + }); + describe('Scale price ladder', () => { const params = { symbol: 'BTC', @@ -1461,6 +1550,56 @@ describe('PerpsController', () => { count: 3, }; + it.each(['ready', 'initializing'] as const)( + 'captures outer and nested inputs before %s provider readiness', + async (readiness) => { + const release = createDeferred(); + let initialization: Promise | undefined; + if (readiness === 'initializing') { + const started = createDeferred(); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => { + throw new Error('Transient initialization failure'); + }); + jest.mocked(mockWait).mockImplementationOnce(() => { + started.resolve(); + return release.promise; + }); + initialization = controller.init(); + await started.promise; + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + } else { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + } + const sizing = { usdAmount: '60', skew: 1 }; + const request: GetScalePriceLadderParams = { + ...params, + providerId: 'hyperliquid', + sizing, + }; + const expected = { ...request, sizing: { ...sizing } }; + + const pending = controller.getScalePriceLadder(request); + request.symbol = 'SOL'; + request.minPrice = 1; + request.maxPrice = 2; + request.count = 2; + request.providerId = 'lighter'; + sizing.usdAmount = '600'; + sizing.skew = 2; + release.resolve(); + await initialization; + + await expect(pending).resolves.toMatchObject({ + status: 'ready', + providerId: 'hyperliquid', + }); + expect(mockProvider.getScalePriceLadder).toHaveBeenCalledWith(expected); + }, + ); + it('uses the active provider when providerId is omitted', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); @@ -2014,6 +2153,519 @@ describe('PerpsController', () => { }); describe('durable-settlement surfacing (manual recoveries / recovered dispatches)', () => { + it.each([ + ['review', 'account'], + ['resolve', 'account'], + ['acknowledge', 'account'], + ['acknowledge', 'network'], + ] as const)( + 'captures the issuing context before %s readiness across %s change', + async (operation, drift) => { + let selectedAccount = createMockEvmAccount(); + const call = jest.fn().mockImplementation((action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [selectedAccount]; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + const invoked = jest.fn().mockResolvedValue({ + status: 'unsupported', + providerId: 'hyperliquid', + success: false, + error: 'unsupported', + reason: 'unsupported', + }); + const provider = Object.assign(mockProvider, { + reviewRecoveryVenue: invoked, + resolveRecoveryProtection: invoked, + acknowledgeRecoveredDispatch: invoked, + }); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => provider); + const entered = createDeferred(); + const release = createDeferred(); + jest.mocked(mockWait).mockImplementationOnce(() => { + entered.resolve(); + return release.promise; + }); + const initialization = controller.init(); + await entered.promise; + const pending = + operation === 'acknowledge' + ? controller.acknowledgeRecoveredDispatch('42:nohash') + : operation === 'review' + ? controller.reviewRecoveryVenue() + : controller.resolveRecoveryProtection({ + symbol: 'BTC', + recoveryId: 'opaque', + }); + const observed = pending.catch((error: unknown) => error); + if (drift === 'account') { + selectedAccount = { + ...selectedAccount, + address: '0x9999999999999999999999999999999999999999', + }; + } else { + controller.testUpdate((state) => { + state.isTestnet = !state.isTestnet; + }); + } + release.resolve(); + await initialization; + expect(await observed).toBeInstanceOf(Error); + expect(invoked).not.toHaveBeenCalled(); + }, + ); + + it.each(['settled', 'unresolved', 'throw', 'stale'] as const)( + 'balances the explicit recovery trace for %s', + async (outcome) => { + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.lastUpdateTimestamp = 1; + }); + const failure = new Error('Provider recovery failed'); + const invoked = jest.fn(async () => { + if (outcome === 'throw') { + throw failure; + } + if (outcome === 'stale') { + controller.testUpdate((state) => { + state.isTestnet = !state.isTestnet; + }); + } + return { + status: + outcome === 'unresolved' + ? ('unresolved' as const) + : ('settled' as const), + providerId: 'hyperliquid' as const, + success: outcome !== 'unresolved', + }; + }); + controller.testSetProviders( + new Map([ + [ + 'hyperliquid', + { ...mockProvider, resolveRecoveryProtection: invoked }, + ], + ]), + ); + const errors: unknown[] = []; + for (let invocation = 0; invocation < 2; invocation += 1) { + await controller + .resolveRecoveryProtection({ symbol: 'BTC', recoveryId: 'opaque' }) + .catch((error: unknown) => { + errors.push(error); + }); + } + const traces = jest + .mocked(mockInfrastructure.tracer.trace) + .mock.calls.filter( + ([entry]) => entry.name === PerpsTraceNames.UpdateTpsl, + ); + const ends = jest + .mocked(mockInfrastructure.tracer.endTrace) + .mock.calls.filter( + ([entry]) => entry.name === PerpsTraceNames.UpdateTpsl, + ); + expect(traces).toHaveLength(2); + expect(traces[0]?.[0].op).toBe(PerpsTraceOperations.PositionManagement); + expect(ends).toHaveLength(2); + expect(traces[0]?.[0].id).not.toBe(traces[1]?.[0].id); + expect(ends.map(([entry]) => entry.id)).toStrictEqual( + traces.map(([entry]) => entry.id), + ); + expect(controller.state.lastUpdateTimestamp === 1).toBe( + outcome !== 'settled', + ); + expect(errors).toHaveLength( + outcome === 'throw' || outcome === 'stale' ? 2 : 0, + ); + expect(errors[0]).toBe(outcome === 'throw' ? failure : errors[0]); + }, + ); + + it.each(['settled', 'unresolved', 'unsupported'] as const)( + 'refreshes state only for a successful settled recovery (%s)', + async (status) => { + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.lastUpdateTimestamp = 1; + }); + const resolveRecoveryProtection = jest.fn().mockResolvedValue({ + status, + providerId: 'hyperliquid', + success: status === 'settled', + }); + controller.testSetProviders( + new Map([ + ['hyperliquid', { ...mockProvider, resolveRecoveryProtection }], + ]), + ); + await controller.resolveRecoveryProtection({ + symbol: 'BTC', + recoveryId: 'opaque', + }); + expect(controller.state.lastUpdateTimestamp === 1).toBe( + status !== 'settled', + ); + }, + ); + + it.each(['getAttachedOrderGroups', 'reviewAttachedOrderGroups'] as const)( + 'fences attached %s results after the issuing network changes', + async (method) => { + markControllerAsInitialized(); + const entered = createDeferred(); + const release = createDeferred(); + const read = jest.fn(async () => { + entered.resolve(); + await release.promise; + return []; + }); + controller.testSetProviders( + new Map([['hyperliquid', { ...mockProvider, [method]: read }]]), + ); + const pending = controller[method](); + const rejected = expect(pending).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + await entered.promise; + controller.testUpdate((state) => { + state.isTestnet = !state.isTestnet; + }); + release.resolve(); + await rejected; + expect(read).toHaveBeenCalledTimes(1); + }, + ); + + it('reports unsupported strict review and selected recovery without calling ordinary trading reads', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + expect(await controller.reviewRecoveryVenue()).toMatchObject({ + status: 'unsupported', + providerId: 'hyperliquid', + }); + expect( + await controller.resolveRecoveryProtection({ + symbol: 'BTC', + recoveryId: 'opaque', + }), + ).toMatchObject({ + status: 'unsupported', + providerId: 'hyperliquid', + success: false, + }); + expect(mockProvider.getPositions).not.toHaveBeenCalled(); + expect(mockProvider.updatePositionTPSL).not.toHaveBeenCalled(); + }); + + it.each(['review', 'resolve', 'acknowledge'] as const)( + 'rejects %s results after the issuing network context changes', + async (operation) => { + markControllerAsInitialized(); + const entered = createDeferred(); + const release = createDeferred(); + const call = jest.fn(async () => { + entered.resolve(); + await release.promise; + return { + status: 'unsupported' as const, + providerId: 'hyperliquid' as const, + success: false as const, + error: 'unsupported', + reason: 'unsupported', + }; + }); + controller.testSetProviders( + new Map([ + [ + 'hyperliquid', + { + ...mockProvider, + reviewRecoveryVenue: call, + resolveRecoveryProtection: call, + acknowledgeRecoveredDispatch: async () => { + await call(); + }, + }, + ], + ]), + ); + const pending = + operation === 'acknowledge' + ? controller.acknowledgeRecoveredDispatch('42:nohash') + : operation === 'review' + ? controller.reviewRecoveryVenue() + : controller.resolveRecoveryProtection({ + symbol: 'BTC', + recoveryId: 'opaque', + }); + const rejected = expect(pending).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + await entered.promise; + controller.testUpdate((state) => { + state.isTestnet = !state.isTestnet; + }); + release.resolve(); + await rejected; + }, + ); + + it.each(['reconcile', 'listing'] as const)( + 'rejects reconciliation issued during toggle cleanup before replacement %s work', + async (capability) => { + await controller.getMarkets({ standalone: true }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + expect(controller.testHasStandaloneProvider()).toBe(true); + + const cleanupStarted = createDeferred(); + const releaseCleanup = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + cleanupStarted.resolve(); + await releaseCleanup.promise; + return { success: true }; + }); + const reconcile = jest.fn().mockResolvedValue([]); + const listing = jest.fn().mockResolvedValue([]); + const replacement = Object.assign(createMockHyperLiquidProvider(), { + getRecoveredDispatches: listing, + ...(capability === 'reconcile' + ? { reconcileRecoveredDispatches: reconcile } + : {}), + }); + jest + .mocked(HyperLiquidProvider) + .mockImplementationOnce(() => replacement); + const initializationStarted = createDeferred(); + const releaseInitialization = createDeferred(); + jest.mocked(mockWait).mockImplementationOnce(() => { + initializationStarted.resolve(); + return releaseInitialization.promise; + }); + + const toggle = controller.toggleTestnet(); + await cleanupStarted.promise; + expect(controller.state.isTestnet).toBe(false); + const pending = controller.reconcileRecoveredDispatches(); + const rejection = expect(pending).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(reconcile).not.toHaveBeenCalled(); + expect(listing).not.toHaveBeenCalled(); + + releaseCleanup.resolve(); + await initializationStarted.promise; + expect(controller.state.isTestnet).toBe(true); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + expect(reconcile).not.toHaveBeenCalled(); + expect(listing).not.toHaveBeenCalled(); + releaseInitialization.resolve(); + await expect(toggle).resolves.toStrictEqual({ + success: true, + isTestnet: true, + }); + await rejection; + expect(reconcile).not.toHaveBeenCalled(); + expect(listing).not.toHaveBeenCalled(); + }, + ); + + it.each(['reconcile', 'listing'] as const)( + 'rejects an account change during initialization before provider %s work', + async (capability) => { + let selectedAccount = createMockEvmAccount(); + const selectedAccountCall = jest.fn(); + selectedAccountCall.mockImplementation((action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [selectedAccount]; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ + call: selectedAccountCall, + }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + const reconcile = jest.fn().mockResolvedValue([]); + const listing = jest.fn().mockResolvedValue([]); + const provider = Object.assign(mockProvider, { + getRecoveredDispatches: listing, + ...(capability === 'reconcile' + ? { reconcileRecoveredDispatches: reconcile } + : {}), + }); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => provider); + const initializationStarted = createDeferred(); + const releaseInitialization = createDeferred(); + jest.mocked(mockWait).mockImplementationOnce(() => { + initializationStarted.resolve(); + return releaseInitialization.promise; + }); + + const initialization = controller.init(); + await initializationStarted.promise; + const pending = controller.reconcileRecoveredDispatches(); + const rejection = expect(pending).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + selectedAccount = { + ...selectedAccount, + address: '0x9999999999999999999999999999999999999999', + }; + expect(reconcile).not.toHaveBeenCalled(); + expect(listing).not.toHaveBeenCalled(); + releaseInitialization.resolve(); + await initialization; + await rejection; + expect(reconcile).not.toHaveBeenCalled(); + expect(listing).not.toHaveBeenCalled(); + }, + ); + + it.each(['reconcile', 'listing', 'none'] as const)( + 'waits for same-context initialization before the %s recovery capability', + async (capability) => { + const rows = [ + { + recoveryId: 'pending', + kind: 13, + intent: 'withdraw', + txHash: null, + outcome: 'unknown' as const, + evidence: 'unresolved-dispatch', + acknowledgeable: false, + }, + ]; + const reconcile = jest.fn().mockResolvedValue(rows); + const listing = jest.fn().mockResolvedValue(rows); + const provider = Object.assign(mockProvider, { + ...(capability !== 'none' ? { getRecoveredDispatches: listing } : {}), + ...(capability === 'reconcile' + ? { reconcileRecoveredDispatches: reconcile } + : {}), + }); + jest.mocked(HyperLiquidProvider).mockImplementationOnce(() => provider); + const initializationStarted = createDeferred(); + const releaseInitialization = createDeferred(); + jest.mocked(mockWait).mockImplementationOnce(() => { + initializationStarted.resolve(); + return releaseInitialization.promise; + }); + const initialization = controller.init(); + await initializationStarted.promise; + let settled = false; + const pending = controller + .reconcileRecoveredDispatches() + .then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + expect(reconcile).not.toHaveBeenCalled(); + expect(listing).not.toHaveBeenCalled(); + releaseInitialization.resolve(); + await initialization; + await expect(pending).resolves.toStrictEqual( + capability === 'none' ? [] : rows, + ); + expect(reconcile).toHaveBeenCalledTimes( + capability === 'reconcile' ? 1 : 0, + ); + expect(listing).toHaveBeenCalledTimes(capability === 'listing' ? 1 : 0); + }, + ); + + it('registers the reconciliation messenger action during initialization', async () => { + const register = jest.fn(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ + registerMethodActionHandlers: register, + }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + markControllerAsInitialized(); + await controller.init(); + expect(register).toHaveBeenCalledWith( + controller, + expect.arrayContaining(['reconcileRecoveredDispatches']), + ); + }); + + it('reconciles through the active provider, falls back to listing and propagates errors', async () => { + markControllerAsInitialized(); + const rows = [ + { + recoveryId: 'pending', + kind: 13, + intent: 'withdraw', + txHash: null, + outcome: 'unknown' as const, + evidence: 'unresolved-dispatch', + acknowledgeable: false, + }, + ]; + const listing = jest.fn().mockResolvedValue(rows); + const reconcile = jest.fn().mockResolvedValue(rows); + controller.testSetProviders( + new Map([ + [ + 'hyperliquid', + { + ...mockProvider, + getRecoveredDispatches: listing, + reconcileRecoveredDispatches: reconcile, + }, + ], + ]), + ); + expect(await controller.reconcileRecoveredDispatches()).toStrictEqual( + rows, + ); + expect(reconcile).toHaveBeenCalledTimes(1); + expect(listing).not.toHaveBeenCalled(); + reconcile.mockRejectedValueOnce(new Error('recovery failed')); + await expect(controller.reconcileRecoveredDispatches()).rejects.toThrow( + 'recovery failed', + ); + controller.testSetProviders( + new Map([ + ['hyperliquid', { ...mockProvider, getRecoveredDispatches: listing }], + ]), + ); + expect(await controller.reconcileRecoveredDispatches()).toStrictEqual( + rows, + ); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + expect(await controller.reconcileRecoveredDispatches()).toStrictEqual([]); + }); + it('returns empty lists when the active provider has no durable settlement state', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); 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 28539123e4d..1db2094b75c 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -852,6 +852,36 @@ describe('PerpsController', () => { ); }); + it.each([true, false])( + 'forwards the public testnet Chase probe setting only on actual testnet=%s', + (isTestnet) => { + const MockLighterConstructor = jest.fn(() => + createMockHyperLiquidProvider(), + ); + const lighter = { chaseTestnetProbe: true }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { ...getDefaultPerpsControllerState(), isTestnet }, + clientConfig: { providerCredentials: { lighter } }, + infrastructure: mockInfrastructure, + }); + controller.testRegisterLighterProvider( + MockLighterConstructor as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + const passed = ( + MockLighterConstructor.mock.calls as unknown as [ + { chaseTestnetProbe?: boolean }, + ][] + )[0][0]; + expect(passed.chaseTestnetProbe).toBe(isTestnet ? true : undefined); + expect(MockLighterConstructor).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet }), + ); + }, + ); + 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 diff --git a/packages/perps-controller/tests/src/PerpsController.recovery-context.test.ts b/packages/perps-controller/tests/src/PerpsController.recovery-context.test.ts new file mode 100644 index 00000000000..7e157efbc69 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.recovery-context.test.ts @@ -0,0 +1,921 @@ +import { + getDefaultPerpsControllerState, + InitializationState, + PerpsController, +} from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { + PerpsActiveProviderMode, + PerpsProvider, + PerpsRecoveredDispatch, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, + SwitchProviderResult, + ToggleTestnetResult, +} from '../../src/types/index.js'; +import { wait } from '../../src/utils/wait.js'; +import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createDeferred, + createKeyringlessMessenger, + createMockInfrastructure, + createMockEvmAccount, +} from '../helpers/serviceMocks.js'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('../../src/providers/HyperLiquidProvider.js'); +jest.mock('../../src/utils/wait.js', () => ({ wait: jest.fn() })); + +const ACCOUNT_A = createMockEvmAccount().address; +const ACCOUNT_B = '0xabcdef1234567890abcdef1234567890abcdef12'; +const LEGACY_ID = '42:nohash'; + +class RecoveryContextController extends PerpsController { + /** + * Supply an initialized provider for the public acknowledgment boundary. + * + * @param provider - Provider with observable local recovery state. + */ + activate(provider: PerpsProvider): void { + this.providers.set('hyperliquid', provider); + this.activeProviderInstance = provider; + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Model host context changes without an unrelated provider setup operation. + * + * @param change - Network or provider selection made by the host. + * @param change.isTestnet - Updated network selection. + * @param change.activeProvider - Updated provider selection. + */ + changeContext(change: { + isTestnet?: boolean; + activeProvider?: PerpsActiveProviderMode; + }): void { + this.update((state) => Object.assign(state, change)); + } +} + +type Fixture = { + controller: RecoveryContextController; + provider: ReturnType & { + acknowledgeRecoveredDispatch: jest.MockedFunction< + NonNullable + >; + }; + acknowledge: jest.MockedFunction< + NonNullable + >; + ledgers: Map>; + selectAccount: (address: `0x${string}`) => void; + host: ReturnType; +}; + +/** + * Create a real controller and host messenger with two colliding legacy IDs. + * Only the provider boundary and initialization delay are mocked. + * + * @param initialized - Whether to start with an already available provider. + * @returns Controller, provider calls, local outcomes and host selection. + */ +function createFixture(initialized = true): Fixture { + const host = createKeyringlessMessenger(); + host.rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ remoteFeatureFlags: {}, cacheTimestamp: 0 }), + ); + host.rootMessenger.delegate({ + actions: ['RemoteFeatureFlagController:getState'], + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger: host.messenger, + }); + let selectedAddress: string = ACCOUNT_A; + const ledgers = new Map([ + [ACCOUNT_A, new Set([LEGACY_ID])], + [ACCOUNT_B, new Set([LEGACY_ID])], + ]); + const acknowledge = jest.fn(async (id: string) => { + ledgers.get(selectedAddress)?.delete(id); + }); + const provider = Object.assign(createMockHyperLiquidProvider(), { + acknowledgeRecoveredDispatch: acknowledge, + }) satisfies PerpsProvider; + const controller = new RecoveryContextController({ + messenger: host.messenger, + state: { ...getDefaultPerpsControllerState(), isTestnet: true }, + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + if (initialized) { + controller.activate(provider); + } + const selectAccount = (address: `0x${string}`): void => { + selectedAddress = address.toLowerCase(); + host.selectAccount(address); + }; + return { controller, provider, acknowledge, ledgers, selectAccount, host }; +} + +const CONTEXT_CHANGES: { + name: string; + change: (fixture: Fixture) => void; +}[] = [ + { name: 'account', change: ({ selectAccount }) => selectAccount(ACCOUNT_B) }, + { + name: 'network', + change: ({ controller }) => controller.changeContext({ isTestnet: false }), + }, + { + name: 'provider mode', + change: ({ controller }) => + controller.changeContext({ activeProvider: 'aggregated' }), + }, +]; + +const ROLLBACK_CHANGES = [ + { + name: 'provider', + change: ( + controller: RecoveryContextController, + ): Promise => controller.switchProvider('aggregated'), + constructorCalls: 4, + }, + { + name: 'network', + change: ( + controller: RecoveryContextController, + ): Promise => controller.toggleTestnet(), + constructorCalls: 3, + }, +]; + +/** + * Fail every target initialization attempt and allow provider-switch rollback. + * Network-toggle rollback restores selection but leaves initialization failed. + * + * @param replacement - New provider available if the rollback reinitializes. + */ +function failTargetInitialization( + replacement: ReturnType, +): void { + let attempts = 0; + jest.mocked(HyperLiquidProvider).mockImplementation(() => { + attempts += 1; + if (attempts <= 3) { + throw new Error('Target provider initialization failed'); + } + return replacement; + }); +} + +describe('PerpsController recovered-dispatch acknowledgment context', () => { + beforeEach(() => { + jest.mocked(wait).mockReset().mockResolvedValue(undefined); + jest.mocked(HyperLiquidProvider).mockReset(); + }); + + it.each(CONTEXT_CHANGES)( + 'refuses stale Scale inventory before provider readiness after $name changes', + async ({ change }) => { + const fixture = createFixture(); + const getScaleOrderGroups = jest.fn().mockResolvedValue([]); + Object.assign(fixture.provider, { getScaleOrderGroups }); + const result = fixture.controller.getScaleOrderGroups(); + change(fixture); + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(getScaleOrderGroups).not.toHaveBeenCalled(); + }, + ); + + it.each(CONTEXT_CHANGES)( + 'refuses stale Scale inventory after awaited provider call and $name changes', + async ({ change }) => { + const fixture = createFixture(); + const review = createDeferred<[]>(); + const entered = createDeferred(); + Object.assign(fixture.provider, { + getScaleOrderGroups: jest.fn(async () => { + entered.resolve(); + return review.promise; + }), + }); + const result = fixture.controller.getScaleOrderGroups(); + await entered.promise; + change(fixture); + review.resolve([]); + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + }, + ); + + it.each(CONTEXT_CHANGES)( + 'refuses stale Scale review after awaited provider call and $name changes', + async ({ change }) => { + const fixture = createFixture(); + const review = createDeferred<[]>(); + const entered = createDeferred(); + Object.assign(fixture.provider, { + reviewScaleOrderGroups: jest.fn(async () => { + entered.resolve(); + return review.promise; + }), + }); + const result = fixture.controller.reviewScaleOrderGroups({ + providerId: 'hyperliquid', + }); + await entered.promise; + change(fixture); + review.resolve([]); + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + }, + ); + + it('requires an explicit Scale review route in aggregated mode', async () => { + const fixture = createFixture(); + fixture.controller.changeContext({ activeProvider: 'aggregated' }); + await expect(fixture.controller.reviewScaleOrderGroups()).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + }); + + it('refuses a Scale review route that differs from the active provider', async () => { + const fixture = createFixture(); + const reviewScaleOrderGroups = jest.fn().mockResolvedValue([]); + Object.assign(fixture.provider, { reviewScaleOrderGroups }); + await expect( + fixture.controller.reviewScaleOrderGroups({ providerId: 'lighter' }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(reviewScaleOrderGroups).not.toHaveBeenCalled(); + }); + + describe('Chase cancellation reconciliation context', () => { + const params = (): ReconcileChaseOrderCancellationParams => ({ + providerId: 'hyperliquid', + handle: 'original-chase', + clientOrderId: '101', + owner: { + providerId: 'hyperliquid', + walletAddress: ACCOUNT_A, + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 7, + }, + cancellation: { nonce: 209, txHash: 'b'.repeat(64), expiresAt: 110000 }, + }); + const unsupported: ReconcileChaseOrderCancellationResult = { + status: 'unsupported', + providerId: 'hyperliquid', + handle: 'original-chase', + reason: 'Test capability unavailable', + }; + + it('reports unsupported without falling back to ordinary cancellation', async () => { + const fixture = createFixture(); + await fixture.controller.init(); + expect( + await fixture.host.messenger.call( + 'PerpsController:reconcileChaseOrderCancellation', + params(), + ), + ).toMatchObject({ status: 'unsupported', providerId: 'hyperliquid' }); + expect(jest.mocked(fixture.provider).cancelOrder.mock.calls).toHaveLength( + 0, + ); + }); + it.each(CONTEXT_CHANGES)( + 'rejects stale reconciliation before readiness after $name changes', + async ({ change }) => { + const fixture = createFixture(); + const reconcile = jest.fn().mockResolvedValue(unsupported); + Object.assign(fixture.provider, { + reconcileChaseOrderCancellation: reconcile, + }); + const result = + fixture.controller.reconcileChaseOrderCancellation(params()); + change(fixture); + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(reconcile).not.toHaveBeenCalled(); + expect( + jest.mocked(fixture.provider).cancelOrder.mock.calls, + ).toHaveLength(0); + }, + ); + it.each(CONTEXT_CHANGES)( + 'rejects stale reconciliation after provider await and $name changes', + async ({ change }) => { + const fixture = createFixture(); + const result = createDeferred(); + const entered = createDeferred(); + const reconcile = jest.fn(async () => { + entered.resolve(); + return result.promise; + }); + Object.assign(fixture.provider, { + reconcileChaseOrderCancellation: reconcile, + }); + await fixture.controller.init(); + const pending = fixture.host.messenger.call( + 'PerpsController:reconcileChaseOrderCancellation', + params(), + ); + await entered.promise; + change(fixture); + result.resolve(unsupported); + await expect(pending).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(reconcile).toHaveBeenCalledTimes(1); + expect( + jest.mocked(fixture.provider).cancelOrder.mock.calls, + ).toHaveLength(0); + }, + ); + it('captures exact caller identity before readiness', async () => { + const fixture = createFixture(); + const input = params(); + const expected = structuredClone(input); + const reconcile = jest.fn().mockResolvedValue(unsupported); + Object.assign(fixture.provider, { + reconcileChaseOrderCancellation: reconcile, + }); + const pending = fixture.controller.reconcileChaseOrderCancellation(input); + input.owner.apiKeyIndex = 8; + input.cancellation.nonce = 210; + expect(await pending).toStrictEqual(unsupported); + expect(reconcile).toHaveBeenCalledWith(expected); + }); + it('rejects a provider route different from the active context', async () => { + const fixture = createFixture(); + const reconcile = jest.fn().mockResolvedValue(unsupported); + Object.assign(fixture.provider, { + reconcileChaseOrderCancellation: reconcile, + }); + const input = params(); + input.providerId = 'lighter'; + input.owner.providerId = 'lighter'; + await expect( + fixture.controller.reconcileChaseOrderCancellation(input), + ).rejects.toThrow(/active context/u); + expect(reconcile).not.toHaveBeenCalled(); + expect(jest.mocked(fixture.provider).cancelOrder.mock.calls).toHaveLength( + 0, + ); + }); + it('propagates a lost provider reply without retry or ordinary cancellation', async () => { + const fixture = createFixture(); + const reconcile = jest + .fn() + .mockRejectedValue(new Error('reconciliation reply lost')); + Object.assign(fixture.provider, { + reconcileChaseOrderCancellation: reconcile, + }); + await expect( + fixture.controller.reconcileChaseOrderCancellation(params()), + ).rejects.toThrow('reconciliation reply lost'); + expect(reconcile).toHaveBeenCalledTimes(1); + expect(jest.mocked(fixture.provider).cancelOrder.mock.calls).toHaveLength( + 0, + ); + }); + }); + + describe.each([ + 'reconcileRecoveredDispatches', + 'getRecoveredDispatches', + ] as const)('reconciliation through %s', (capability) => { + const dispatches: PerpsRecoveredDispatch[] = [ + { + recoveryId: LEGACY_ID, + walletAddress: ACCOUNT_A, + kind: 1, + intent: 'Recovered test dispatch', + txHash: null, + outcome: 'unknown', + evidence: 'unresolved', + }, + ]; + + it.each(CONTEXT_CHANGES)( + 'refuses stale reconciliation before provider readiness after $name changes', + async ({ change }) => { + const fixture = createFixture(); + const read = jest.fn().mockResolvedValue(dispatches); + Object.assign(fixture.provider, { [capability]: read }); + + const result = fixture.controller.reconcileRecoveredDispatches(); + change(fixture); + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(read).not.toHaveBeenCalled(); + }, + ); + + it.each(CONTEXT_CHANGES)( + 'refuses stale reconciliation after awaited provider call and $name changes', + async ({ change }) => { + const fixture = createFixture(); + const completion = createDeferred(); + const entered = createDeferred(); + const read = jest.fn(async () => { + entered.resolve(); + return completion.promise; + }); + Object.assign(fixture.provider, { [capability]: read }); + + const result = fixture.controller.reconcileRecoveredDispatches(); + await entered.promise; + change(fixture); + completion.resolve(dispatches); + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(read).toHaveBeenCalledTimes(1); + }, + ); + + it('returns the exact provider result after an unchanged-context await', async () => { + const fixture = createFixture(); + const completion = createDeferred(); + const entered = createDeferred(); + const read = jest.fn(async () => { + entered.resolve(); + return completion.promise; + }); + Object.assign(fixture.provider, { [capability]: read }); + + const result = fixture.controller.reconcileRecoveredDispatches(); + await entered.promise; + completion.resolve(dispatches); + + expect(await result).toBe(dispatches); + expect(read).toHaveBeenCalledTimes(1); + }); + + it.each(CONTEXT_CHANGES)( + 'preserves readiness rejection after $name changes', + async ({ change }) => { + const fixture = createFixture(false); + const read = jest.fn().mockResolvedValue(dispatches); + Object.assign(fixture.provider, { [capability]: read }); + + const result = fixture.controller.reconcileRecoveredDispatches(); + change(fixture); + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + expect(read).not.toHaveBeenCalled(); + }, + ); + + it.each(CONTEXT_CHANGES)( + 'preserves the exact provider rejection after $name changes in flight', + async ({ change }) => { + const fixture = createFixture(); + const completion = createDeferred(); + const entered = createDeferred(); + const failure = new Error('Original reconciliation rejection'); + const read = jest.fn(async () => { + entered.resolve(); + return completion.promise; + }); + Object.assign(fixture.provider, { [capability]: read }); + + const result = fixture.controller.reconcileRecoveredDispatches(); + await entered.promise; + change(fixture); + completion.reject(failure); + + await expect(result).rejects.toBe(failure); + expect(read).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('returns an empty reconciliation when neither provider capability is available', async () => { + const { controller } = createFixture(); + + expect(await controller.reconcileRecoveredDispatches()).toStrictEqual([]); + }); + + it.each(CONTEXT_CHANGES)( + 'refuses acknowledgment after $name changes while readiness returns', + async ({ change }) => { + const fixture = createFixture(); + const { controller, acknowledge, ledgers } = fixture; + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + change(fixture); + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it('refuses acknowledgment after the selected account disappears', async () => { + const { controller, acknowledge, host, ledgers } = createFixture(); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + host.deselectAccount(); + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + // Direct-provider initialization covers account/network drift here. Provider + // mode drift is covered at readiness return and during provider completion. + it.each(CONTEXT_CHANGES.filter(({ name }) => name !== 'provider mode'))( + 'refuses acknowledgment after $name changes during initialization', + async ({ change }) => { + const fixture = createFixture(false); + const { controller, acknowledge, ledgers } = fixture; + const readiness = createDeferred(); + jest.mocked(wait).mockReturnValueOnce(readiness.promise); + jest + .mocked(HyperLiquidProvider) + .mockImplementation(() => fixture.provider); + const initialization = controller.init(); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + change(fixture); + readiness.resolve(); + await initialization; + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it.each(CONTEXT_CHANGES)( + 'rejects a completed acknowledgment after $name changes in flight', + async ({ change }) => { + const fixture = createFixture(); + const { controller, acknowledge, ledgers } = fixture; + const started = createDeferred(); + const completion = createDeferred(); + acknowledge.mockImplementationOnce(async (id) => { + started.resolve(); + await completion.promise; + ledgers.get(ACCOUNT_A)?.delete(id); + }); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + await started.promise; + change(fixture); + completion.resolve(); + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith(LEGACY_ID); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set()); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it('rejects a retired lifetime while readiness waits without redirecting to its replacement', async () => { + const { controller, provider, acknowledge, ledgers } = createFixture(false); + const readiness = createDeferred(); + jest.mocked(wait).mockReturnValueOnce(readiness.promise); + jest.mocked(HyperLiquidProvider).mockImplementation(() => provider); + const replacement = Object.assign(createMockHyperLiquidProvider(), { + acknowledgeRecoveredDispatch: jest.fn(async () => undefined), + }); + const initialization = controller.init(); + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + const disconnected = controller.disconnect(); + const replaced = disconnected.then(async () => { + jest.mocked(HyperLiquidProvider).mockImplementation(() => replacement); + await controller.init(); + }); + readiness.resolve(); + await initialization; + await replaced; + + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(replacement.acknowledgeRecoveredDispatch).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + it.each(['success', 'rejection'] as const)( + 'preserves issuing storage and %s after same-context lifetime retirement', + async (outcome) => { + const { controller, acknowledge, ledgers } = createFixture(); + const started = createDeferred(); + const completion = createDeferred(); + const failure = new Error('Original retired-provider rejection'); + const replacement = Object.assign(createMockHyperLiquidProvider(), { + acknowledgeRecoveredDispatch: jest.fn(async () => undefined), + }); + acknowledge.mockImplementationOnce(async (id) => { + started.resolve(); + await completion.promise; + if (outcome === 'rejection') { + throw failure; + } + ledgers.get(ACCOUNT_A)?.delete(id); + }); + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + await started.promise; + + await controller.disconnect(); + controller.activate(replacement); + completion.resolve(); + + const error = await settled; + expect(error).toStrictEqual( + outcome === 'success' + ? new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) + : failure, + ); + expect(outcome === 'rejection' ? error === failure : true).toBe(true); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith(LEGACY_ID); + expect(replacement.acknowledgeRecoveredDispatch).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual( + new Set(outcome === 'success' ? [] : [LEGACY_ID]), + ); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it.each(ROLLBACK_CHANGES)( + 'refuses held readiness after a failed $name switch restores the context', + async ({ change, constructorCalls }) => { + const { controller, acknowledge, ledgers, provider } = createFixture(); + const disconnect = jest.spyOn(provider, 'disconnect'); + const replacement = Object.assign(createMockHyperLiquidProvider(), { + acknowledgeRecoveredDispatch: jest.fn(async () => undefined), + }); + failTargetInitialization(replacement); + + const rollback = change(controller); + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + const transition = await rollback; + + expect(transition.success).toBe(false); + expect(HyperLiquidProvider).toHaveBeenCalledTimes(constructorCalls); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(controller.state.activeProvider).toBe('hyperliquid'); + expect(controller.state.isTestnet).toBe(true); + expect(await settled).toStrictEqual( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(replacement.acknowledgeRecoveredDispatch).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it.each( + ROLLBACK_CHANGES.flatMap((change) => + (['success', 'rejection'] as const).map((outcome) => ({ + ...change, + outcome, + })), + ), + )( + 'preserves issuing storage and $outcome after a failed $name switch', + async ({ change, constructorCalls, outcome }) => { + const { controller, acknowledge, ledgers, provider } = createFixture(); + const disconnect = jest.spyOn(provider, 'disconnect'); + const replacement = Object.assign(createMockHyperLiquidProvider(), { + acknowledgeRecoveredDispatch: jest.fn(async () => undefined), + }); + const started = createDeferred(); + const completion = createDeferred(); + const failure = new Error('Original provider rejection after rollback'); + acknowledge.mockImplementationOnce(async (id) => { + started.resolve(); + await completion.promise; + if (outcome === 'rejection') { + throw failure; + } + ledgers.get(ACCOUNT_A)?.delete(id); + }); + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + await started.promise; + failTargetInitialization(replacement); + + const transition = await change(controller); + completion.resolve(); + const error = await settled; + + expect(transition.success).toBe(false); + expect(HyperLiquidProvider).toHaveBeenCalledTimes(constructorCalls); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(controller.state.activeProvider).toBe('hyperliquid'); + expect(controller.state.isTestnet).toBe(true); + expect(error).toStrictEqual( + outcome === 'success' + ? new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) + : failure, + ); + expect(error === failure).toBe(outcome === 'rejection'); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith(LEGACY_ID); + expect(replacement.acknowledgeRecoveredDispatch).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual( + new Set(outcome === 'success' ? [] : [LEGACY_ID]), + ); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it('reports unavailable initialization for a new acknowledgment after a failed network switch', async () => { + const { controller, acknowledge, ledgers, provider } = createFixture(); + const disconnect = jest.spyOn(provider, 'disconnect'); + failTargetInitialization(createMockHyperLiquidProvider()); + + const transition = await controller.toggleTestnet(); + + expect(transition.success).toBe(false); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(controller.state.initializationState).toBe( + InitializationState.Failed, + ); + expect(controller.isCurrentlyReinitializing()).toBe(false); + await expect( + controller.acknowledgeRecoveredDispatch(LEGACY_ID), + ).rejects.toThrow(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + expect(acknowledge).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + it('acknowledges through a new initialization after a failed network switch', async () => { + const { controller, acknowledge, ledgers } = createFixture(); + failTargetInitialization(createMockHyperLiquidProvider()); + const transition = await controller.toggleTestnet(); + expect(transition.success).toBe(false); + const readiness = createDeferred(); + jest.mocked(wait).mockReturnValueOnce(readiness.promise); + const replacement = Object.assign(createMockHyperLiquidProvider(), { + acknowledgeRecoveredDispatch: jest.fn(async (id: string) => { + ledgers.get(ACCOUNT_A)?.delete(id); + }), + }); + jest.mocked(HyperLiquidProvider).mockImplementation(() => replacement); + const initialization = controller.init(); + expect(controller.state.initializationState).toBe( + InitializationState.Initializing, + ); + expect(controller.isCurrentlyReinitializing()).toBe(false); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + readiness.resolve(); + await initialization; + + expect(await settled).toBeUndefined(); + expect(controller.getActiveProvider()).toBe(replacement); + expect(acknowledge).not.toHaveBeenCalled(); + expect(replacement.acknowledgeRecoveredDispatch).toHaveBeenCalledTimes(1); + expect(replacement.acknowledgeRecoveredDispatch).toHaveBeenCalledWith( + LEGACY_ID, + ); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set()); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + it('acknowledges the exact legacy ID in an unchanged context', async () => { + const { controller, acknowledge, ledgers } = createFixture(); + + await controller.acknowledgeRecoveredDispatch(LEGACY_ID); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith(LEGACY_ID); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set()); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + it('acknowledges after initialization in an unchanged context', async () => { + const { controller, provider, acknowledge, ledgers } = createFixture(false); + const readiness = createDeferred(); + jest.mocked(wait).mockReturnValueOnce(readiness.promise); + jest.mocked(HyperLiquidProvider).mockImplementation(() => provider); + const initialization = controller.init(); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + readiness.resolve(); + await initialization; + await result; + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set()); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + it('preserves cold-start readiness failure when no provider lifetime existed', async () => { + const { controller, acknowledge, ledgers } = createFixture(false); + jest.mocked(HyperLiquidProvider).mockImplementation(() => { + throw new Error('Cold-start initialization failed'); + }); + const initialization = controller.init(); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + await initialization; + + expect(await settled).toStrictEqual( + new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED), + ); + expect(acknowledge).not.toHaveBeenCalled(); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); + + it.each(CONTEXT_CHANGES)( + 'preserves the exact provider rejection after $name changes in flight', + async ({ change }) => { + const fixture = createFixture(); + const { controller, acknowledge, ledgers } = fixture; + const started = createDeferred(); + const completion = createDeferred(); + const failure = new Error('Original provider rejection'); + acknowledge.mockImplementationOnce(async () => { + started.resolve(); + await completion.promise; + throw failure; + }); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + await started.promise; + change(fixture); + completion.resolve(); + + await expect(result).rejects.toBe(failure); + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(acknowledge).toHaveBeenCalledWith(LEGACY_ID); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }, + ); + + it('preserves provider rejection without clearing either ledger', async () => { + const { controller, acknowledge, ledgers } = createFixture(); + const failure = new Error('Recovery ID is not eligible'); + acknowledge.mockRejectedValueOnce(failure); + + const result = controller.acknowledgeRecoveredDispatch(LEGACY_ID); + + await expect(result).rejects.toBe(failure); + expect(ledgers.get(ACCOUNT_A)).toStrictEqual(new Set([LEGACY_ID])); + expect(ledgers.get(ACCOUNT_B)).toStrictEqual(new Set([LEGACY_ID])); + }); +}); diff --git a/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts b/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts index 7f9f0adab91..7c1075a2014 100644 --- a/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts +++ b/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts @@ -310,6 +310,28 @@ describe('SubscriptionMultiplexer', () => { }); describe('subscribeToOrders', () => { + it.each(['subscribeToOrders', 'subscribeToOrderFills'] as const)( + 'forwards a provider authentication failure through %s', + (method) => { + const onError = jest.fn(); + const failure = new Error('Lighter authentication failed'); + const unsubscribe = mux[method]({ + providers: [ + ['lighter', mockLighterProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + onError, + }); + const subscribe = mockLighterProvider[method]; + if (!subscribe) { + throw new Error('Missing mocked subscription'); + } + jest.mocked(subscribe).mock.calls[0][0].onError?.(failure); + + expect(onError).toHaveBeenCalledWith(failure, 'lighter'); + unsubscribe(); + }, + ); const createMockOrder = (orderId: string, symbol: string): Order => ({ orderId, @@ -344,6 +366,7 @@ describe('SubscriptionMultiplexer', () => { providerId: 'hyperliquid', }), ]), + 'hyperliquid', ); }); @@ -415,6 +438,7 @@ describe('SubscriptionMultiplexer', () => { }), ]), false, + 'hyperliquid', ); }); @@ -430,7 +454,11 @@ describe('SubscriptionMultiplexer', () => { mockHLProvider._emitFills([createMockFill('fill-1', 'BTC')], true); - expect(callback).toHaveBeenCalledWith(expect.any(Array), true); + expect(callback).toHaveBeenCalledWith( + expect.any(Array), + true, + 'hyperliquid', + ); }); }); diff --git a/packages/perps-controller/tests/src/constants/lighterConfig.test.ts b/packages/perps-controller/tests/src/constants/lighterConfig.test.ts index d144d0290c8..e967a52b7d9 100644 --- a/packages/perps-controller/tests/src/constants/lighterConfig.test.ts +++ b/packages/perps-controller/tests/src/constants/lighterConfig.test.ts @@ -1,3 +1,4 @@ +import * as publicConstants from '../../../src/constants/index.js'; import { computeLighterMinOrderSize, fromLighterInteger, @@ -13,8 +14,24 @@ import { parseLighterStrictDecimal, toLighterInteger, } from '../../../src/constants/lighterConfig.js'; +import * as lighterPolicy from '../../../src/constants/lighterConfig.js'; describe('lighterConfig', () => { + it.each([ + ['LIGHTER_SCALE_SETTLEMENT_WINDOW_MS', 10000], + ['LIGHTER_SCALE_SETTLEMENT_POLL_MS', 250], + ['LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS', 10000], + ['LIGHTER_CHASE_CANCEL_SETTLEMENT_POLL_MS', 250], + ] as const)( + 'exports the %s settlement policy through both constants entrypoints', + (name, value) => { + const policy: Record = lighterPolicy; + const exported: Record = publicConstants; + expect(policy[name]).toBe(value); + expect(exported[name]).toBe(value); + }, + ); + describe('chain ids', () => { it('returns testnet chain id 300', () => { expect(getLighterChainId('testnet')).toBe(300); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index e97593d01e8..8fc12c21b92 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -15,6 +15,8 @@ import type { ChaseOrder, TwapOrder, FeeCalculationParams, + ReconcileChaseOrderCancellationParams, + ReconcileChaseOrderCancellationResult, } from '../../../src/types/index.js'; import { WebSocketConnectionState } from '../../../src/types/index.js'; import { STRATEGY_ORDER_TYPES } from '../../../src/utils/orderTypes.js'; @@ -216,7 +218,142 @@ describe('AggregatedPerpsProvider', () => { routedProvider = aggregatedProvider; }); + it('lists optional Scale inventory and reviews only the explicit owning provider', async () => { + const groups = [ + { + groupId: 'opaque-scale', + symbol: 'BTC', + state: 'unknown', + accountIndex: 28, + apiKeyIndex: 7, + walletAddress: '0xabc', + network: 'testnet', + providerId: 'lighter', + }, + ]; + const reviewScaleOrderGroups = jest.fn().mockResolvedValue(groups); + Object.assign(mockLighterProvider, { + getScaleOrderGroups: jest.fn().mockResolvedValue(groups), + reviewScaleOrderGroups, + }); + expect(await aggregatedProvider.getScaleOrderGroups()).toEqual(groups); + await expect(aggregatedProvider.reviewScaleOrderGroups()).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_FOUND, + ); + await expect( + aggregatedProvider.reviewScaleOrderGroups({ providerId: 'hyperliquid' }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + expect(reviewScaleOrderGroups).not.toHaveBeenCalled(); + expect( + await aggregatedProvider.reviewScaleOrderGroups({ + providerId: 'lighter', + }), + ).toEqual(groups); + expect(reviewScaleOrderGroups).toHaveBeenCalledTimes(1); + }); + describe('durable-settlement surfacing', () => { + it.each(['getAttachedOrderGroups', 'reviewAttachedOrderGroups'] as const)( + 'routes attached %s to the implementing provider and retains its identity', + async (method) => { + const group = { + groupId: 'group', + providerId: 'hyperliquid', + symbol: 'BTC', + submission: 'unknown', + parentClientOrderId: '101', + childClientOrderIds: ['102'], + cancellation: 'explicit-exact-owned-orders', + }; + const read = jest.fn().mockResolvedValue([group]); + const provider = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + ['lighter', { ...mockLighterProvider, [method]: read }], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + expect(await provider[method]()).toStrictEqual([ + { ...group, providerId: 'lighter' }, + ]); + expect(read).toHaveBeenCalledTimes(1); + }, + ); + + it('routes strict review and selected protection to only the explicit owner', async () => { + const review = { + status: 'ready' as const, + providerId: 'lighter' as const, + walletAddress: '0xabc', + network: 'testnet', + accountIndex: 28, + positions: [], + orders: [], + reviewedAt: 5, + }; + const reviewRecoveryVenue = jest.fn().mockResolvedValue(review); + const resolveRecoveryProtection = jest.fn().mockResolvedValue({ + status: 'settled', + providerId: 'lighter', + success: true, + }); + const provider = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + [ + 'lighter', + { + ...mockLighterProvider, + reviewRecoveryVenue, + resolveRecoveryProtection, + }, + ], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + expect( + await provider.reviewRecoveryVenue({ providerId: 'lighter' }), + ).toStrictEqual(review); + expect( + await provider.resolveRecoveryProtection({ + providerId: 'lighter', + recoveryId: 'opaque', + symbol: 'BTC', + stopLossPrice: '1', + }), + ).toMatchObject({ status: 'settled', success: true }); + expect(reviewRecoveryVenue).toHaveBeenCalledTimes(1); + expect(resolveRecoveryProtection).toHaveBeenCalledWith({ + providerId: 'lighter', + recoveryId: 'opaque', + symbol: 'BTC', + stopLossPrice: '1', + }); + expect( + await provider.reviewRecoveryVenue({ providerId: 'hyperliquid' }), + ).toMatchObject({ status: 'unsupported', providerId: 'hyperliquid' }); + expect( + await provider.resolveRecoveryProtection({ + providerId: 'hyperliquid', + recoveryId: 'opaque', + symbol: 'BTC', + }), + ).toMatchObject({ status: 'unsupported', success: false }); + await expect(provider.reviewRecoveryVenue()).rejects.toThrow( + /explicit provider/u, + ); + await expect( + provider.resolveRecoveryProtection({ + recoveryId: 'opaque', + symbol: 'BTC', + }), + ).rejects.toThrow(/explicit provider/u); + expect(mockHLProvider.getPositions).not.toHaveBeenCalled(); + expect(mockHLProvider.getOpenOrders).not.toHaveBeenCalled(); + }); + const pending = { symbol: 'BTC', settlementKey: '0xabc:28:7:BTC', @@ -235,6 +372,67 @@ describe('AggregatedPerpsProvider', () => { evidence: 'tx-status:3', }; + it('returns only Lighter rows when HyperLiquid has no recovery capabilities', async () => { + const reconcile = jest.fn().mockResolvedValue([outcome]); + const listing = jest + .fn() + .mockResolvedValue([{ ...outcome, recoveryId: 'old' }]); + expect(mockHLProvider.reconcileRecoveredDispatches).toBeUndefined(); + expect(mockHLProvider.getRecoveredDispatches).toBeUndefined(); + const aggregated = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + [ + 'lighter', + { + ...mockLighterProvider, + getRecoveredDispatches: listing, + reconcileRecoveredDispatches: reconcile, + }, + ], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + + expect(await aggregated.reconcileRecoveredDispatches()).toStrictEqual([ + { ...outcome, providerId: 'lighter' }, + ]); + expect(reconcile).toHaveBeenCalledTimes(1); + expect(listing).not.toHaveBeenCalled(); + }); + + it('reconciles all providers with listing fallback and propagates failures', async () => { + const reconcile = jest.fn().mockResolvedValue([outcome]); + const listing = jest + .fn() + .mockResolvedValue([{ ...outcome, recoveryId: 'other' }]); + const aggregated = new AggregatedPerpsProvider({ + providers: new Map([ + [ + 'hyperliquid', + { ...mockHLProvider, getRecoveredDispatches: listing }, + ], + [ + 'lighter', + { ...mockLighterProvider, reconcileRecoveredDispatches: reconcile }, + ], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + expect(await aggregated.reconcileRecoveredDispatches()).toStrictEqual([ + { ...outcome, recoveryId: 'other', providerId: 'hyperliquid' }, + { ...outcome, providerId: 'lighter' }, + ]); + expect(listing).toHaveBeenCalledTimes(1); + expect(reconcile).toHaveBeenCalledTimes(1); + reconcile.mockRejectedValueOnce(new Error('recovery failed')); + await expect(aggregated.reconcileRecoveredDispatches()).rejects.toThrow( + 'recovery failed', + ); + }); + it('aggregates recoveries and outcomes from providers implementing the contract', async () => { const durable = { ...mockLighterProvider, @@ -252,10 +450,10 @@ describe('AggregatedPerpsProvider', () => { }); // The non-durable provider contributes empty lists, never an error. expect(await aggregated.getPendingManualRecoveries()).toStrictEqual([ - pending, + { ...pending, providerId: 'lighter' }, ]); expect(await aggregated.getRecoveredDispatches()).toStrictEqual([ - outcome, + { ...outcome, providerId: 'lighter' }, ]); await aggregated.acknowledgeRecoveredDispatch('42:abcd'); expect( @@ -1418,6 +1616,81 @@ describe('AggregatedPerpsProvider', () => { }); }); + describe('Chase cancellation reconciliation routing', () => { + const params = (): ReconcileChaseOrderCancellationParams => ({ + providerId: 'lighter', + handle: 'lighter-chase:100', + clientOrderId: '101', + owner: { + providerId: 'lighter', + walletAddress: '0xabc', + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 7, + }, + cancellation: { nonce: 209, txHash: 'b'.repeat(64), expiresAt: 110000 }, + }); + const unsupported: ReconcileChaseOrderCancellationResult = { + status: 'unsupported', + providerId: 'lighter', + handle: 'lighter-chase:100', + reason: 'Test capability unavailable', + }; + + it('routes once to the exact provider and preserves captured identity', async () => { + const reconcile = jest.fn().mockResolvedValue(unsupported); + mockLighterProvider.reconcileChaseOrderCancellation = reconcile; + const input = params(); + const expected = structuredClone(input); + const pending = aggregatedProvider.reconcileChaseOrderCancellation(input); + input.owner.apiKeyIndex = 8; + input.cancellation.nonce = 210; + expect(await pending).toStrictEqual(unsupported); + expect(reconcile).toHaveBeenCalledTimes(1); + expect(reconcile).toHaveBeenCalledWith(expected); + expect(mockHLProvider.cancelOrder).not.toHaveBeenCalled(); + expect(mockLighterProvider.cancelOrder).not.toHaveBeenCalled(); + }); + it('reports unsupported without an ordinary cancellation fallback', async () => { + expect( + await aggregatedProvider.reconcileChaseOrderCancellation(params()), + ).toMatchObject({ status: 'unsupported', providerId: 'lighter' }); + expect(mockHLProvider.cancelOrder).not.toHaveBeenCalled(); + expect(mockLighterProvider.cancelOrder).not.toHaveBeenCalled(); + }); + it('refuses a missing exact route rather than using the default provider', async () => { + aggregatedProvider.removeProvider('lighter'); + await expect( + aggregatedProvider.reconcileChaseOrderCancellation(params()), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + expect(mockHLProvider.cancelOrder).not.toHaveBeenCalled(); + }); + it('refuses mismatched owner and route before provider calls', async () => { + const reconcile = jest.fn().mockResolvedValue(unsupported); + mockLighterProvider.reconcileChaseOrderCancellation = reconcile; + const input = params(); + input.owner.providerId = 'hyperliquid'; + await expect( + aggregatedProvider.reconcileChaseOrderCancellation(input), + ).rejects.toThrow(/matching owner/u); + expect(reconcile).not.toHaveBeenCalled(); + }); + it('fences a provider replacement during reconciliation', async () => { + const result = createDeferred(); + mockLighterProvider.reconcileChaseOrderCancellation = jest.fn( + () => result.promise, + ); + const pending = + aggregatedProvider.reconcileChaseOrderCancellation(params()); + aggregatedProvider.addProvider('lighter', createMockProvider('lighter')); + result.resolve(unsupported); + await expect(pending).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(mockLighterProvider.cancelOrder).not.toHaveBeenCalled(); + }); + }); + describe('Provider Management', () => { it('adds new provider', () => { // Using 'lighter' as an existing valid provider type for this test @@ -1726,6 +1999,10 @@ describe('AggregatedPerpsProvider', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: Object.freeze(['twap', 'scale', 'chase']), + supportedTriggerOrderTypes: Object.freeze([ + 'stop_market', + 'take_profit_limit', + ]), supportedMarginModes: Object.freeze(['isolated', 'cross']), }); mockHLProvider.getOrderCapabilities.mockResolvedValue(capabilities); @@ -1744,6 +2021,19 @@ describe('AggregatedPerpsProvider', () => { status: 'ready', providerId: 'lighter', supportedStrategies: [], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_market', + stopLossOrderType: 'stop_market', + defaultCoverage: 'position-snapshot', + partialCoverage: { + single: true, + pair: 'equal-quantity-oco', + replacement: 'cancel-before-create', + recovery: 'explicit-current-position-intent', + }, + }, }); await expect( @@ -1755,6 +2045,19 @@ describe('AggregatedPerpsProvider', () => { status: 'ready', providerId: 'lighter', supportedStrategies: [], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_market', + stopLossOrderType: 'stop_market', + defaultCoverage: 'position-snapshot', + partialCoverage: { + single: true, + pair: 'equal-quantity-oco', + replacement: 'cancel-before-create', + recovery: 'explicit-current-position-intent', + }, + }, }); expect(mockLighterProvider.getOrderCapabilities).toHaveBeenCalledWith({ symbol: 'BTC', diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts index e7cd13aae19..aa945ae433b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts @@ -2482,26 +2482,46 @@ describe('HyperLiquidProvider', () => { stopLossCount: 0, }; - it('places partial TP/SL as standalone reduce-only triggers', async () => { + it.each([undefined, 'independent'] as const)( + 'places partial TP/SL as standalone reduce-only triggers linkage=%s', + async (partialPairLinkage) => { + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + partialPairLinkage, + takeProfitPrice: '60000', + takeProfitSize: '0.04', + stopLossPrice: '45000', + stopLossSize: '0.06', + position, + }); + + expect(result.success).toBe(true); + const request = ( + mockClientService.getExchangeClient().order as jest.Mock + ).mock.calls[0][0]; + // A quantity cannot be expressed under positionTpsl grouping + expect(request.grouping).toBe('na'); + expect(request.orders).toHaveLength(2); + expect(request.orders[0].s).toBe('0.04'); + expect(request.orders[0].r).toBe(true); + expect(request.orders[1].s).toBe('0.06'); + expect(request.orders[1].r).toBe(true); + }, + ); + + it('rejects an explicit native OCO linkage before trading setup', async () => { const result = await provider.updatePositionTPSL({ symbol: 'BTC', takeProfitPrice: '60000', - takeProfitSize: '0.04', stopLossPrice: '45000', - stopLossSize: '0.06', - position, + takeProfitSize: '0.04', + stopLossSize: '0.04', + partialPairLinkage: 'equal-quantity-oco', }); - - expect(result.success).toBe(true); - const request = (mockClientService.getExchangeClient().order as jest.Mock) - .mock.calls[0][0]; - // A quantity cannot be expressed under positionTpsl grouping - expect(request.grouping).toBe('na'); - expect(request.orders).toHaveLength(2); - expect(request.orders[0].s).toBe('0.04'); - expect(request.orders[0].r).toBe(true); - expect(request.orders[1].s).toBe('0.06'); - expect(request.orders[1].r).toBe(true); + expect(result.success).toBe(false); + expect( + mockClientService.getExchangeClient().order, + ).not.toHaveBeenCalled(); }); it('keeps whole-position TP/SL on positionTpsl grouping with size 0', async () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-dispatch-refusal.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-dispatch-refusal.test.ts new file mode 100644 index 00000000000..f17bb52892f --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-dispatch-refusal.test.ts @@ -0,0 +1,295 @@ +import type { Hex } from '@metamask/utils'; +import { HttpTransport, WebSocketTransport } from '@nktkas/hyperliquid'; + +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { + HyperLiquidClientService, + HyperLiquidWalletParams, +} from '../../../src/services/HyperLiquidClientService.js'; +import { + AGENT_ADDRESS, + L1_PAYLOAD, + MAINNET_ACCOUNT, + MAIN_ADDRESS, + OTHER_MAIN_ADDRESS, + mustDepositError, +} from '../../helpers/agentFixtures.js'; +import { + apiRequestError, + createAccountSignerProvider, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import { + createDeferred, + createMockInfrastructure, +} from '../../helpers/serviceMocks.js'; + +// Keep SDK signing, dispatch guards and reporting real; only its network +// transports and the provider's unrelated reads/subscriptions are mocked. +jest.mock('@nktkas/hyperliquid', () => ({ + ...jest.requireActual( + '@nktkas/hyperliquid', + ), + HttpTransport: jest.fn(), + WebSocketTransport: jest.fn(), +})); +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +const { HyperLiquidClientService: RealClientService } = jest.requireActual< + typeof import('../../../src/services/HyperLiquidClientService.js') +>('../../../src/services/HyperLiquidClientService.js'); + +const CANCEL = { cancels: [{ a: 0, o: 123 }] }; + +type TrackedSdkFixture = ReturnType & { + realService: HyperLiquidClientService; + registry: Map; + request: jest.Mock; + getAgentSigner: jest.Mock; + onAgentRejected: jest.Mock; + onExchangeRequest: jest.Mock< + void, + [unknown, unknown, HyperLiquidWalletParams?] + >; +}; + +/** + * Observe the actual provider registry when it binds its wallet, without a + * production test hook or a replacement registry. + * + * @returns The registry observer and its restoration function. + */ +function observeSignatureRegistry(): { + registry: () => Map; + restore: () => void; +} { + let registry: Map | undefined; + const originalSet = Reflect.get(WeakMap.prototype, 'set') as ( + this: WeakMap, + key: object, + value: unknown, + ) => WeakMap; + const observer = jest + .spyOn(WeakMap.prototype, 'set') + .mockImplementation(function ( + this: WeakMap, + key: object, + value: unknown, + ) { + if ( + typeof value === 'object' && + value !== null && + 'unansweredSignatures' in value && + value.unansweredSignatures instanceof Map + ) { + registry = value.unansweredSignatures as Map; + } + return originalSet.call(this, key, value); + }); + return { + registry: () => { + if (!registry) { + throw new Error('The provider did not register its SDK wallet'); + } + return registry; + }, + restore: () => observer.mockRestore(), + }; +} + +describe('HyperLiquidProvider agent signatures after dispatch refusal', () => { + let service: HyperLiquidClientService | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + setUpAccountSignerSuite(); + }); + + afterEach(async () => { + await service?.disconnect(); + service = undefined; + jest.restoreAllMocks(); + }); + + /** + * Connect the provider's real tracked wallet to the real client service and + * SDK, with distinct valid signatures and a mocked HTTP boundary. + * + * @returns The provider, real SDK service, registry and transport mock. + */ + async function createTrackedSdkFixture(): Promise { + const observer = observeSignatureRegistry(); + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const built = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + info: { extraAgents: jest.fn().mockResolvedValue([]) }, + }); + getAgentSigner.mockResolvedValue(built.agentSigner); + await built.accountSignerProvider.prepareTradingWallet(); + const registry = observer.registry(); + observer.restore(); + let signatureNumber = 0; + built.agentSigner.signTypedData.mockImplementation( + async (): Promise => { + signatureNumber += 1; + return `0x${signatureNumber.toString(16).padStart(64, '0')}${'11'.repeat(32)}1b`; + }, + ); + const request = jest.fn(); + jest + .mocked(HttpTransport) + .mockImplementation( + () => ({ isTestnet: false, request }) as unknown as HttpTransport, + ); + jest.mocked(WebSocketTransport).mockImplementation( + () => + ({ + isTestnet: false, + ready: jest.fn().mockResolvedValue(undefined), + close: jest.fn(), + socket: { addEventListener: jest.fn() }, + }) as unknown as WebSocketTransport, + ); + const onExchangeRequest = jest.fn(built.reportExchangeRequest); + const realService = new RealClientService(createMockInfrastructure(), { + onExchangeRequest, + }); + service = realService; + await realService.initialize(built.sdkWallet()); + return { + ...built, + realService, + registry, + request, + getAgentSigner, + onAgentRejected, + onExchangeRequest, + }; + } + + it.each(['position changed', 'scope changed'])( + 'retires distinct signatures refused with %s without dispatching or dropping the cached signer', + async (message) => { + const built = await createTrackedSdkFixture(); + const refusal = new Error(message); + const guard = jest.fn().mockRejectedValue(refusal); + const guarded = built.realService.getExchangeClient(guard); + + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(guarded.cancel(CANCEL)).rejects.toBe(refusal); + expect(built.registry.size).toBe(0); + } + + expect(guard).toHaveBeenCalledTimes(5); + expect(built.request).not.toHaveBeenCalled(); + expect(built.onAgentRejected).not.toHaveBeenCalled(); + expect(built.getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + ]); + }, + ); + + it('reports a refused signature with its original wallet after the service changes account and network', async () => { + const built = await createTrackedSdkFixture(); + const originalWallet = built.sdkWallet(); + const guardEntered = createDeferred(); + const guardAnswer = createDeferred(); + const refusal = new Error('scope changed'); + const guarded = built.realService.getExchangeClient(async () => { + guardEntered.resolve(); + await guardAnswer.promise; + }); + const refused = guarded.cancel(CANCEL).catch((error: unknown) => error); + await guardEntered.promise; + expect(built.registry.size).toBe(1); + const replacementWallet = { + ...originalWallet, + address: OTHER_MAIN_ADDRESS, + }; + + await built.realService.toggleTestnet(replacementWallet); + guardAnswer.reject(refusal); + expect(await refused).toBe(refusal); + + expect(built.onExchangeRequest).toHaveBeenCalledTimes(1); + expect(built.onExchangeRequest.mock.calls[0]?.[0]).toMatchObject({ + action: { type: 'cancel', ...CANCEL }, + signature: { r: `0x${'1'.padStart(64, '0')}` }, + }); + expect(built.onExchangeRequest.mock.calls[0]?.[1]).toBeUndefined(); + expect(built.onExchangeRequest.mock.calls[0]?.[2]).toBe(originalWallet); + expect(built.realService.getExchangeClient().config_.wallet).toBe( + replacementWallet, + ); + expect(built.realService.isTestnetMode()).toBe(true); + expect(built.registry.size).toBe(0); + expect(built.request).not.toHaveBeenCalled(); + expect(built.onAgentRejected).not.toHaveBeenCalled(); + expect(built.getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + }); + + it('preserves a pending signature and attributes its later answer to the original account', async () => { + const built = await createTrackedSdkFixture(); + const dispatched = createDeferred(); + const answer = createDeferred>(); + built.request.mockImplementation(async () => { + dispatched.resolve(); + return await answer.promise; + }); + const shared = built.realService.getExchangeClient(); + // The SDK serializes a wallet's writes through response settlement. Send + // this already-signed request at its reporting transport boundary to keep + // one actual registry entry in flight while the real SDK refuses others. + built.exchangeClient.cancel.mockImplementation(async () => { + const signature = await built.sdkWallet().signTypedData(L1_PAYLOAD); + const response = await shared.config_.transport.request< + Record + >('exchange', { + action: { type: 'cancel', ...CANCEL }, + signature: { r: signature.slice(0, 66) }, + }); + throw apiRequestError(response); + }); + + const pending = built.accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await dispatched.promise; + const pendingEntries = [...built.registry.entries()]; + built.selectAccount(OTHER_MAIN_ADDRESS); + const refusal = new Error('scope changed'); + const guarded = built.realService.getExchangeClient(async () => { + throw refusal; + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(guarded.cancel(CANCEL)).rejects.toBe(refusal); + expect([...built.registry.entries()]).toStrictEqual(pendingEntries); + } + answer.resolve({ + status: 'err', + response: mustDepositError(MAIN_ADDRESS).message, + }); + const result = await pending; + + expect(pendingEntries).toHaveLength(1); + expect(built.request).toHaveBeenCalledTimes(1); + expect(built.registry.size).toBe(0); + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(built.onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(built.getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [{ mainAddress: OTHER_MAIN_ADDRESS, isTestnet: false }], + ]); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-must-deposit.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-must-deposit.test.ts index f1e2f417a5e..099fffd9cdb 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-must-deposit.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-must-deposit.test.ts @@ -26,6 +26,7 @@ import { setUpAccountSignerSuite, } from '../../helpers/hyperLiquidAccountSignerFixture.js'; import type { AccountSignerFixture } from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import { createFrontendOpenOrder } from '../../helpers/providerMocks.js'; import { createDeferred } from '../../helpers/serviceMocks.js'; // The SDK ships ES modules only; the provider reaches it through the mocked @@ -172,6 +173,141 @@ describe('HyperLiquidProvider with accountSigner: an agent the venue answers wit return { ...built, getAgentSigner, onAgentRejected, extraAgents }; } + it.each([false, true])( + 'awaits replacement signer classification before restoration, accountChanged=%s', + async (accountChanged) => { + const asked = createDeferred(); + const listing = createDeferred(); + const built = await createPreparedAgentProvider(async () => { + asked.resolve(); + return await listing.promise; + }); + const replacementAgent = { + address: OTHER_AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(OTHER_AGENT_SIGNATURE), + }; + built.getAgentSigner.mockResolvedValue(replacementAgent); + built.infoClient.frontendOpenOrders.mockResolvedValue([ + createFrontendOpenOrder({ + side: 'A', + oid: 456, + orderType: 'Take Profit Market', + isTrigger: true, + triggerPx: '58000', + triggerCondition: 'Price above 58000', + reduceOnly: true, + isPositionTpsl: true, + }), + ]); + built.exchangeClient.cancel.mockResolvedValue(cancelAnswer(['success'])); + built.exchangeClient.order.mockImplementationOnce( + async () => + await built.signAndSend(L1_PAYLOAD, mustDepositAnswer(MAIN_ADDRESS)), + ); + + const updating = built.accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + await asked.promise; + await new Promise((resolve) => setImmediate(resolve)); + const writesWhileClassifying = + built.exchangeClient.order.mock.calls.length; + const restorationSignedEarly = + replacementAgent.signTypedData.mock.calls.length; + if (accountChanged) { + built.selectAccount(OTHER_MAIN_ADDRESS); + } + listing.resolve([]); + const result = await updating; + + expect(writesWhileClassifying).toBe(1); + expect(restorationSignedEarly).toBe(0); + expect(result).toStrictEqual( + accountChanged + ? { + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + } + : { + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + childOrderIds: ['123'], + }, + ); + expect(built.exchangeClient.order).toHaveBeenCalledTimes( + accountChanged ? 1 : 2, + ); + expect(replacementAgent.signTypedData).toHaveBeenCalledTimes( + accountChanged ? 0 : 1, + ); + expect(built.onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + }, + ); + + it('awaits the cancellation error callback before returning a mixed multisig and agent rejection', async () => { + const asked = createDeferred(); + const listing = createDeferred(); + const built = await createPreparedAgentProvider(async () => { + asked.resolve(); + return await listing.promise; + }); + built.infoClient.frontendOpenOrders.mockResolvedValue( + [456, 457].map((oid) => + createFrontendOpenOrder({ + side: 'A', + oid, + orderType: 'Take Profit Market', + isTrigger: true, + triggerPx: '58000', + triggerCondition: 'Price above 58000', + reduceOnly: true, + isPositionTpsl: true, + }), + ), + ); + built.exchangeClient.cancel.mockImplementation( + async () => + await built.signAndSend( + L1_PAYLOAD, + cancelAnswer([ + { error: 'Multi-sig required' }, + { error: mustDepositError(MAIN_ADDRESS).message }, + ]), + ), + ); + let settled = false; + + const updating = built.accountSignerProvider + .updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }) + .then((result) => { + settled = true; + return result; + }); + await asked.promise; + await new Promise((resolve) => setImmediate(resolve)); + const settledWhileClassifying = settled; + listing.resolve([]); + const result = await updating; + + expect(settledWhileClassifying).toBe(false); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(built.exchangeClient.order).not.toHaveBeenCalled(); + expect(built.extraAgents).toHaveBeenCalledTimes(1); + expect(built.onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + describe.each([ { venue: 'no longer lists the agent', diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts index d768f770ef1..95b9e46bbee 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -387,6 +387,36 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); + it('invalidates the signed agent after a definitive top-level replacement rejection', async () => { + const { + accountSignerProvider, + exchangeClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('order'); + await accountSignerProvider.getMarketDataWithPrices(); + exchangeClient.order.mockImplementation(async () => { + await sdkWallet().signTypedData(L1_PAYLOAD); + const rejection = unknownWalletError(AGENT_ADDRESS); + throw Object.assign(rejection, { + response: { status: 'err', response: rejection.message }, + }); + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + }); + it('keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected', async () => { const { accountSignerProvider, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 3def4a52d62..5fdc537edc4 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -1,4 +1,4 @@ -import { HyperliquidError } from '@nktkas/hyperliquid'; +import { ExchangeClient, HyperliquidError } from '@nktkas/hyperliquid'; import type { MetaResponse } from '@nktkas/hyperliquid'; import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; @@ -26,6 +26,7 @@ import type { OrderResult, } from '../../../src/types/index.js'; import type { OrderType } from '../../../src/types/perps-types.js'; +import { createGuardedHyperLiquidClient } from '../../../src/utils/guardedHyperLiquidClient.js'; import { HYPERLIQUID_SCALE_CLOID_MARKER } from '../../../src/utils/hyperLiquidAdapter.js'; import { validateAssetSupport, @@ -37,6 +38,7 @@ import { } from '../../../src/utils/hyperLiquidValidation.js'; import { hasFeeReductionAppliedFlag, + isSubscriptionProgramCloid, readSubscriptionCloidFlags, } from '../../../src/utils/subscriptionFeeWaiver.js'; import { createMockPosition } from '../../helpers/providerMocks.js'; @@ -46,11 +48,6 @@ import { createMockMessenger, } from '../../helpers/serviceMocks.js'; -// The HyperLiquid SDK is never exercised directly: every exchange and info call -// goes through the mocked client service below. -jest.mock('@nktkas/hyperliquid', () => ({ - HyperliquidError: class MockHyperliquidError extends Error {}, -})); jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidWalletService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); @@ -733,6 +730,36 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }); + it('refuses a Lighter expected Scale ladder without silently dropping its constraint', async () => { + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'scale', + scaleMinPrice: '90000', + scaleMaxPrice: '100000', + scaleNumOrders: 2, + expectedScaleLadder: { + prices: ['90000', '100000'], + sizes: ['0.0005', '0.0005'], + totalSize: '0.001', + totalNotional: '95', + minimumBaseSize: '0.0002', + minimumQuoteAmount: '10', + sizeDecimals: 5, + }, + }; + expect(await provider.placeOrder(params)).toStrictEqual({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(await provider.validateOrder(params)).toStrictEqual({ + isValid: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(mockClientService.getExchangeClient.mock.calls).toHaveLength(0); + }); + describe('native TWAP margin-mode occupancy', () => { const order: OrderParams = { ...baseOrder, @@ -1205,113 +1232,109 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(exchangeClient.order).not.toHaveBeenCalled(); }); - it('restores whole-position protection when replacement fails after pre-cancel', async () => { - const order = jest - .fn() - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses: [{ error: 'Rejected replacement' }] }, - }, - }) - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses: [{ resting: { oid: 789 } }] }, + it.each([ + ['entry-drift', PERPS_ERROR_CODES.TPSL_UPDATE_FAILED], + ['read-failure', expect.any(String) as unknown], + ])( + 'restores exact old protection for %s after confirmed cancellation', + async (change, expectedError) => { + const { exchangeClient, infoClient } = useStrategyClients({ + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1700000000000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), }, }); - const { exchangeClient } = useStrategyClients({ - exchange: { order }, - info: { - frontendOpenOrders: jest.fn().mockResolvedValue([ + exchangeClient.cancel.mockImplementation(async () => { + if (change === 'read-failure') { + infoClient.clearinghouseState.mockRejectedValue(new Error('429')); + } else { + infoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { position: { coin: 'ETH', szi: '1.5', entryPx: '3001' } }, + ], + }); + } + return { + status: 'ok', + response: { data: { statuses: ['success'] } }, + }; + }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + expectedPosition: { size: '1.5', entryPrice: '3000' }, + }); + expect(result).toMatchObject({ + success: false, + error: expectedError, + }); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(exchangeClient.order.mock.calls[0][0]).toMatchObject({ + grouping: 'positionTpsl', + orders: [ { - coin: 'ETH', - side: 'A', - limitPx: '3400', - sz: '0', - origSz: '0', - oid: 456, - timestamp: 1_700_000_000_000, - reduceOnly: true, - isTrigger: true, - isPositionTpsl: true, - triggerCondition: 'Price above 3400', - triggerPx: '3400', - orderType: 'Take Profit Limit', - children: [], - }, - ]), - }, - }); - - const result = await provider.updatePositionTPSL({ - symbol: 'ETH', - takeProfitPrice: '3500', - }); - - expect(result).toMatchObject({ - success: false, - error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, - }); - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [{ a: 1, o: 456 }], - }); - expect(order).toHaveBeenCalledTimes(2); - expect(order.mock.calls[1][0]).toMatchObject({ - grouping: 'positionTpsl', - orders: [ - { - a: 1, - b: false, - p: '3400', - s: '0', - r: true, - t: { - trigger: { - isMarket: false, - triggerPx: '3400', - tpsl: 'tp', + r: true, + s: '0', + p: '3400', + t: { + trigger: { isMarket: false, triggerPx: '3400', tpsl: 'tp' }, }, }, - }, - ], - }); - expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( - order.mock.invocationCallOrder[0], - ); - expect(order.mock.invocationCallOrder[0]).toBeLessThan( - order.mock.invocationCallOrder[1], - ); - }); - - it.each([ - { - label: 'returns a rejected status', - restoreResult: { - status: 'ok', - response: { - data: { statuses: [{ error: 'Rejected restoration' }] }, - }, - }, - }, - { - label: 'throws', - restoreResult: new Error('Restoration unavailable'), - }, - ])( - 'reports lost protection when restoration $label', - async ({ restoreResult }) => { - const order = jest.fn().mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses: [{ error: 'Rejected replacement' }] }, - }, + ], }); - if (restoreResult instanceof Error) { - order.mockRejectedValueOnce(restoreResult); - } else { - order.mockResolvedValueOnce(restoreResult); - } + }, + ); + + it.each(['account', 'network', 'disconnect'])( + 'omits restoration receipts after %s changes while restoring', + async (change) => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }) + .mockImplementationOnce(async () => { + if (change === 'account') { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x2222222222222222222222222222222222222222', + ); + } else if (change === 'network') { + mockClientService.isTestnetMode.mockReturnValue(true); + } else { + await provider.disconnect(); + } + return { + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 901 } }, + { error: 'Incomplete restoration' }, + ], + }, + }, + }; + }); useStrategyClients({ exchange: { order }, info: { @@ -1323,7 +1346,7 @@ describe('HyperLiquidProvider - strategy order types', () => { sz: '0', origSz: '0', oid: 456, - timestamp: 1_700_000_000_000, + timestamp: 1700000000000, reduceOnly: true, isTrigger: true, isPositionTpsl: true, @@ -1335,92 +1358,968 @@ describe('HyperLiquidProvider - strategy order types', () => { ]), }, }); - const result = await provider.updatePositionTPSL({ symbol: 'ETH', takeProfitPrice: '3500', }); - - expect(result).toStrictEqual({ - success: false, - error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, - childOrderIds: [], - }); expect(order).toHaveBeenCalledTimes(2); + expect(result.success).toBe(false); + expect(result.childOrderIds).toBeUndefined(); }, ); - it('reports recreated protection IDs when restoration is incomplete', async () => { - const order = jest - .fn() - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { - statuses: [ - { error: 'Rejected take profit' }, - { error: 'Rejected stop loss' }, - ], - }, + it.each(['size', 'entry', 'account', 'network', 'disconnect'])( + 'fences real SDK dispatch after signing when %s changes', + async (change) => { + const { infoClient } = useStrategyClients({ + info: { + referral: jest.fn().mockResolvedValue({ + referredBy: { + referrer: '0x1111111111111111111111111111111111111111', + code: 'EXISTING', + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(100000), }, - }) - .mockResolvedValueOnce({ + }); + const signing = createDeferred(); + const release = createDeferred(); + const request = jest.fn().mockResolvedValue({ status: 'ok', response: { - data: { statuses: [{ resting: { oid: 789 } }] }, + type: 'order', + data: { statuses: [{ resting: { oid: 777 } }] }, }, }); - useStrategyClients({ - exchange: { - order, - cancel: jest.fn().mockResolvedValue({ - status: 'ok', - response: { data: { statuses: ['success', 'success'] } }, - }), - }, - info: { - frontendOpenOrders: jest.fn().mockResolvedValue([ - { - coin: 'ETH', - side: 'A', - limitPx: '2450', - sz: '0', - origSz: '0', - oid: 456, - timestamp: 1_700_000_000_000, - reduceOnly: true, - isTrigger: true, - isPositionTpsl: true, - triggerCondition: 'Price below 2500', - triggerPx: '2500', - orderType: 'Stop Market', - children: [], - }, - { - coin: 'ETH', - side: 'A', - limitPx: '3400', - sz: '0', - origSz: '0', - oid: 457, - timestamp: 1_700_000_000_000, - reduceOnly: true, - isTrigger: true, - isPositionTpsl: true, - triggerCondition: 'Price above 3400', - triggerPx: '3400', - orderType: 'Take Profit Limit', - children: [], + const sdk = new ExchangeClient({ + transport: { isTestnet: false, request }, + wallet: { + address: '0x1111111111111111111111111111111111111111', + signTypedData: async (_args: unknown): Promise<`0x${string}`> => { + signing.resolve(); + await release.promise; + return `0x${'11'.repeat(64)}1b`; }, - ]), - }, - }); + }, + }); + mockClientService.getExchangeClient.mockImplementation( + (beforeDispatch?: () => Promise) => + beforeDispatch + ? createGuardedHyperLiquidClient(sdk, beforeDispatch) + : sdk, + ); + const pending = provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + expectedPosition: { size: '1.5', entryPrice: '3000' }, + }); + await Promise.race([ + signing.promise, + pending.then((result) => { + throw new Error(JSON.stringify(result)); + }), + ]); + if (change === 'size' || change === 'entry') { + infoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'ETH', + szi: change === 'size' ? '2' : '1.5', + entryPx: change === 'entry' ? '3001' : '3000', + }, + }, + ], + }); + } else if (change === 'account') { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x2222222222222222222222222222222222222222', + ); + } else if (change === 'network') { + mockClientService.isTestnetMode.mockReturnValue(true); + } else { + await provider.disconnect(); + } + release.resolve(); + const result = await pending; + expect(result.success).toBe(false); + expect(request).not.toHaveBeenCalled(); + }, + ); - const result = await provider.updatePositionTPSL({ - symbol: 'ETH', - takeProfitPrice: '3500', - stopLossPrice: '2400', - }); + it.each(['partial-error', 'truncated-unrelated'])( + 'reconciles real SDK %s using exact signed child identities', + async (mode) => { + const old = { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1700000000000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }; + let submittedCloid: string | undefined; + const actions: { + type: string; + cancels?: { o: number }[]; + orders?: { c?: string }[]; + }[] = []; + const { infoClient } = useStrategyClients({ + info: { + referral: jest.fn().mockResolvedValue({ + referredBy: { + referrer: '0x1111111111111111111111111111111111111111', + code: 'EXISTING', + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(100000), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([old]) + .mockResolvedValue([ + { + ...old, + oid: 999, + triggerPx: '3500', + limitPx: '3500', + cloid: '0x99999999999999999999999999999999', + }, + ]), + orderStatus: jest + .fn() + .mockImplementation(async ({ oid }: { oid: string }) => + mode === 'partial-error' && oid === submittedCloid + ? { + status: 'order', + order: { + status: 'open', + order: { coin: 'ETH', cloid: oid, oid: 777 }, + }, + } + : { status: 'unknownOid' }, + ), + }, + }); + let orderRequests = 0; + const request = jest + .fn() + .mockImplementation( + async ( + _endpoint: string, + payload: { action: (typeof actions)[number] }, + ) => { + actions.push(payload.action); + if (payload.action.type === 'cancel') { + return { + status: 'ok', + response: { + type: 'cancel', + data: { + statuses: payload.action.cancels?.map(() => 'success'), + }, + }, + }; + } + orderRequests += 1; + if (orderRequests === 1) { + submittedCloid = payload.action.orders?.[0].c; + return { + status: 'ok', + response: { + type: 'order', + data: { + statuses: + mode === 'partial-error' + ? ['waitingForTrigger', { error: 'SL rejected' }] + : ['waitingForTrigger'], + }, + }, + }; + } + return { + status: 'ok', + response: { + type: 'order', + data: { statuses: [{ resting: { oid: 901 } }] }, + }, + }; + }, + ); + const sdk = new ExchangeClient({ + transport: { isTestnet: false, request }, + wallet: { + address: '0x1111111111111111111111111111111111111111', + signTypedData: async (_args: unknown): Promise<`0x${string}`> => + `0x${'11'.repeat(64)}1b`, + }, + }); + mockClientService.getExchangeClient.mockImplementation( + (beforeDispatch?: () => Promise) => + beforeDispatch + ? createGuardedHyperLiquidClient(sdk, beforeDispatch) + : sdk, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + expect(result.success).toBe(false); + const cancelled = actions.flatMap( + (action) => action.cancels?.map((cancel) => cancel.o) ?? [], + ); + expect(cancelled).not.toContain(999); + expect(cancelled).toStrictEqual( + mode === 'partial-error' ? [456, 777] : [456], + ); + expect(actions.map((action) => action.type)).toStrictEqual( + mode === 'partial-error' + ? ['cancel', 'order', 'cancel', 'order'] + : ['cancel', 'order'], + ); + expect(infoClient.orderStatus).toHaveBeenCalledWith( + expect.objectContaining({ oid: submittedCloid }), + ); + expect(orderRequests).toBe(mode === 'partial-error' ? 2 : 1); + expect(result.error).toBe( + mode === 'partial-error' + ? 'order 1: SL rejected' + : PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + ); + }, + ); + + describe('real SDK protection recovery boundaries', () => { + type SignedAction = { + type: string; + orders?: { c?: string }[]; + cancels?: { o: number }[]; + }; + const setup = ( + oldCount = 1, + ): { + infoClient: ReturnType['infoClient']; + actions: SignedAction[]; + hooks: { + sign: () => Promise; + response: (action: SignedAction) => Promise; + }; + defaultResponse: (action: SignedAction) => unknown; + request: jest.Mock; + } => { + const old = { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1700000000000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }; + const { infoClient } = useStrategyClients({ + info: { + referral: jest.fn().mockResolvedValue({ + referredBy: { + referrer: '0x1111111111111111111111111111111111111111', + code: 'EXISTING', + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(100000), + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + frontendOpenOrders: jest.fn().mockResolvedValue( + oldCount === 1 + ? [old] + : [ + old, + { + ...old, + oid: 457, + orderType: 'Stop Market', + triggerCondition: 'Price below 2500', + triggerPx: '2500', + limitPx: '2500', + }, + ], + ), + orderStatus: jest.fn().mockResolvedValue({ status: 'unknownOid' }), + }, + }); + const actions: SignedAction[] = []; + const defaultResponse = (action: SignedAction): unknown => ({ + status: 'ok', + response: { + type: action.type, + data: { + statuses: + action.type === 'cancel' + ? action.cancels?.map(() => 'success') + : action.orders?.map((_order, index) => ({ + resting: { oid: 901 + index }, + })), + }, + }, + }); + const hooks: { + sign: () => Promise; + response: (action: SignedAction) => Promise; + } = { + sign: async () => undefined, + response: async (action) => defaultResponse(action), + }; + const request = jest + .fn() + .mockImplementation( + async (_endpoint: string, payload: { action: SignedAction }) => { + actions.push(payload.action); + return hooks.response(payload.action); + }, + ); + const sdk = new ExchangeClient({ + transport: { isTestnet: false, request }, + wallet: { + address: '0x1111111111111111111111111111111111111111', + signTypedData: async (_args: unknown): Promise<`0x${string}`> => { + await hooks.sign(); + return `0x${'11'.repeat(64)}1b`; + }, + }, + }); + mockClientService.getExchangeClient.mockImplementation( + (guard?: () => Promise) => + guard ? createGuardedHyperLiquidClient(sdk, guard) : sdk, + ); + return { infoClient, actions, hooks, defaultResponse, request }; + }; + + it.each(['top-level', 'bulk'])( + 'restores exact old protection after definitive %s rejection and retains venue text', + async (kind) => { + const { actions, hooks, defaultResponse } = setup(); + let attempts = 0; + hooks.response = async (action): Promise => { + if (action.type === 'order') { + attempts += 1; + } + if (action.type === 'order' && attempts === 1) { + return kind === 'top-level' + ? { + status: 'err', + response: 'Too many cumulative requests sent', + } + : { + status: 'ok', + response: { + type: 'order', + data: { + statuses: [ + { error: 'Too many cumulative requests sent' }, + ], + }, + }, + }; + } + return defaultResponse(action); + }; + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + expect(actions.map((action) => action.type)).toStrictEqual([ + 'cancel', + 'order', + 'order', + ]); + expect(result.success).toBe(false); + expect(result.error).toContain('Too many cumulative requests sent'); + expect(result.childOrderIds).toStrictEqual(['901']); + }, + ); + + it.each([ + ['size', PERPS_ERROR_CODES.TPSL_UPDATE_FAILED], + ['entry', PERPS_ERROR_CODES.TPSL_UPDATE_FAILED], + ['account', expect.any(String) as unknown], + ['network', expect.any(String) as unknown], + ['disconnect', expect.any(String) as unknown], + ['read-failure', expect.any(String) as unknown], + ])( + 'preserves old protection when %s changes during real cancellation signing', + async (change, expectedError) => { + const { actions, hooks, infoClient } = setup(); + const started = createDeferred(); + const release = createDeferred(); + hooks.sign = async (): Promise => { + started.resolve(); + await release.promise; + }; + const pending = provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + expectedPosition: { size: '1.5', entryPrice: '3000' }, + }); + await started.promise; + if (change === 'account') { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x2222222222222222222222222222222222222222', + ); + } else if (change === 'network') { + mockClientService.isTestnetMode.mockReturnValue(true); + } else if (change === 'disconnect') { + await provider.disconnect(); + } else if (change === 'read-failure') { + infoClient.clearinghouseState.mockRejectedValue(new Error('429')); + } else { + infoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'ETH', + szi: change === 'size' ? '2' : '1.5', + entryPx: change === 'entry' ? '3001' : '3000', + }, + }, + ], + }); + } + release.resolve(); + const result = await pending; + expect(actions).toStrictEqual([]); + expect(result.success).toBe(false); + expect(result.error).toStrictEqual(expectedError); + expect(result.error).not.toBe(PERPS_ERROR_CODES.TPSL_PROTECTION_LOST); + expect(result.childOrderIds).toBeUndefined(); + }, + ); + + it.each(['triggered', 'futureScheduledState'])( + 'retains exact child identity while status %s is in flight', + async (status) => { + const { actions, hooks, infoClient, defaultResponse } = setup(); + hooks.response = async (action): Promise => + action.type === 'order' + ? { + status: 'ok', + response: { + type: 'order', + data: { + statuses: ['waitingForTrigger', { error: 'SL rejected' }], + }, + }, + } + : defaultResponse(action); + infoClient.orderStatus.mockImplementation( + async ({ oid }: { oid: string }) => ({ + status: 'order', + order: { status, order: { coin: 'ETH', cloid: oid, oid: 777 } }, + }), + ); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + expect(actions.map((action) => action.type)).toStrictEqual([ + 'cancel', + 'order', + ]); + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['777'], + }); + }, + ); + + it.each(['partial', 'response-loss'])( + 'preserves accepted restoration identities after %s', + async (mode) => { + const { actions, hooks, infoClient, defaultResponse } = setup(2); + let attempts = 0; + let restorationCloid: string | undefined; + hooks.response = async (action): Promise => { + if (action.type === 'order') { + attempts += 1; + if (attempts === 1) { + return { + status: 'ok', + response: { + type: 'order', + data: { statuses: [{ error: 'Replacement rejected' }] }, + }, + }; + } + restorationCloid = action.orders?.[0].c; + if (mode === 'response-loss') { + throw new Error('response lost'); + } + return { + status: 'ok', + response: { + type: 'order', + data: { + statuses: [ + { resting: { oid: 901 } }, + { error: 'SL restoration rejected' }, + ], + }, + }, + }; + } + return defaultResponse(action); + }; + infoClient.orderStatus.mockImplementation( + async ({ oid }: { oid: string }) => + oid === restorationCloid + ? { + status: 'order', + order: { + status: 'open', + order: { coin: 'ETH', cloid: oid, oid: 901 }, + }, + } + : { status: 'unknownOid' }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + expect(actions.map((action) => action.type)).toStrictEqual([ + 'cancel', + 'order', + 'order', + ]); + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['901'], + }); + }, + ); + + it.each(['proactive', 'venue-race'])( + 'retains live replacement IDs and loss when cleanup hits %s multisig conversion', + async (mode) => { + const { actions, hooks, infoClient, defaultResponse } = setup(); + const errorLog = jest.spyOn(mockPlatformDependencies.logger, 'error'); + hooks.response = async (action): Promise => { + if (action.type === 'order') { + return { + status: 'ok', + response: { + type: 'order', + data: { + statuses: [ + { resting: { oid: 777 } }, + { error: 'SL rejected' }, + ], + }, + }, + }; + } + return actions.length === 3 + ? { status: 'err', response: 'Multi-sig required' } + : defaultResponse(action); + }; + hooks.sign = async (): Promise => { + if (mode === 'proactive' && actions.length === 2) { + infoClient.userToMultiSigSigners.mockResolvedValue({ + authorizedUsers: ['0x1111111111111111111111111111111111111111'], + threshold: 1, + }); + } + }; + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['777'], + }); + expect(actions.map((action) => action.type)).toStrictEqual( + mode === 'proactive' + ? ['cancel', 'order'] + : ['cancel', 'order', 'cancel'], + ); + expect(errorLog.mock.calls).toMatchObject([ + [ + { message: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED }, + { + context: { + data: { + method: 'updatePositionTPSL', + stage: 'cleanup', + protectionStatus: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: ['777'], + }, + }, + }, + ], + ]); + }, + ); + + it('reports a definitive restoration failure with protection context', async () => { + const { hooks, defaultResponse } = setup(); + const errorLog = jest.spyOn(mockPlatformDependencies.logger, 'error'); + hooks.response = async (action): Promise => + action.type === 'order' + ? { status: 'err', response: 'Order price too far' } + : defaultResponse(action); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + expect(errorLog.mock.calls).toMatchObject([ + [ + { message: 'Order price too far' }, + { + context: { + data: { + method: 'updatePositionTPSL', + stage: 'restoration', + protectionStatus: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + }, + }, + }, + ], + ]); + }); + + it.each(['replacement', 'restoration'])( + 'blocks transport when account becomes multisig during %s signing', + async (stage) => { + const { actions, hooks, infoClient, defaultResponse } = setup(); + const errorLog = jest.spyOn(mockPlatformDependencies.logger, 'error'); + hooks.response = async (action): Promise => + action.type === 'order' + ? { status: 'err', response: 'Replacement rejected' } + : defaultResponse(action); + hooks.sign = async (): Promise => { + if (actions.length === (stage === 'replacement' ? 1 : 2)) { + infoClient.userToMultiSigSigners.mockResolvedValue({ + authorizedUsers: ['0x1111111111111111111111111111111111111111'], + threshold: 1, + }); + } + }; + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + expect(actions.map((action) => action.type)).toStrictEqual( + stage === 'replacement' ? ['cancel'] : ['cancel', 'order'], + ); + expect(errorLog.mock.calls).toMatchObject([ + [ + { message: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED }, + { context: { data: { stage: 'restoration' } } }, + ], + ]); + }, + ); + + it.each(['proactive', 'venue-race'])( + 'rejects multisig protection writes at the %s boundary', + async (mode) => { + const { actions, hooks, infoClient } = setup(); + infoClient.userToMultiSigSigners.mockResolvedValue( + mode === 'proactive' + ? { + authorizedUsers: [ + '0x1111111111111111111111111111111111111111', + ], + threshold: 1, + } + : null, + ); + hooks.response = async (): Promise => ({ + status: 'err', + response: 'Multi-sig required', + }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + expect(result.success).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + ); + expect(actions).toHaveLength(mode === 'proactive' ? 0 : 1); + expect(infoClient.userToMultiSigSigners).toHaveBeenCalled(); + }, + ); + }); + + it.each([false, true])( + 'restores whole-position protection when replacement fails after pre-cancel, marked=%s', + async (marked) => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + if (marked) { + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + subscriptionWaiverKind: 'full', + }); + } + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 456 }], + }); + expect(order).toHaveBeenCalledTimes(2); + const requests = order.mock.calls as [{ orders: { c?: string }[] }][]; + const restorationCloid = requests[1][0].orders[0].c; + expect(isSubscriptionProgramCloid(restorationCloid)).toBe(true); + expect(hasFeeReductionAppliedFlag(restorationCloid)).toBe(marked); + expect(readSubscriptionCloidFlags(restorationCloid)).toBe( + marked ? SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied : 0, + ); + expect(restorationCloid).not.toBe(requests[0][0].orders[0].c); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'positionTpsl', + orders: [ + { + a: 1, + b: false, + p: '3400', + s: '0', + r: true, + t: { + trigger: { + isMarket: false, + triggerPx: '3400', + tpsl: 'tp', + }, + }, + }, + ], + }); + expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( + order.mock.invocationCallOrder[0], + ); + expect(order.mock.invocationCallOrder[0]).toBeLessThan( + order.mock.invocationCallOrder[1], + ); + }, + ); + + it.each([ + { + label: 'returns a rejected status', + restoreResult: { + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected restoration' }] }, + }, + }, + }, + { + label: 'throws', + restoreResult: new Error('Restoration unavailable'), + }, + ])( + 'reports lost protection when restoration $label', + async ({ restoreResult }) => { + const order = jest.fn().mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }); + if (restoreResult instanceof Error) { + order.mockRejectedValueOnce(restoreResult); + } else { + order.mockResolvedValueOnce(restoreResult); + } + useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + expect(order).toHaveBeenCalledTimes(2); + }, + ); + + it('reports recreated protection IDs when restoration is incomplete', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Rejected take profit' }, + { error: 'Rejected stop loss' }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + useStrategyClients({ + exchange: { + order, + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price below 2500', + triggerPx: '2500', + orderType: 'Stop Market', + children: [], + }, + { + coin: 'ETH', + side: 'A', + limitPx: '3400', + sz: '0', + origSz: '0', + oid: 457, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price above 3400', + triggerPx: '3400', + orderType: 'Take Profit Limit', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2400', + }); expect(result).toStrictEqual({ success: false, @@ -1539,85 +2438,360 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(order.mock.calls[1][0].orders).toHaveLength(1); }); - it('restores a standalone trigger with its remaining size and builder fee', async () => { - const order = jest - .fn() - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses: [{ error: 'Rejected replacement' }] }, - }, - }) - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses: [{ resting: { oid: 789 } }] }, - }, + it('restores a standalone trigger with its remaining size and builder fee', async () => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Rejected replacement' }] }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 789 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(BUILDER_FEE_CONFIG.MaxFeeDecimal), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0.4', + origSz: '0.6', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: false, + triggerCondition: 'Price below 2500', + triggerPx: '2500', + orderType: 'Stop Market', + children: [], + }, + ]), + }, + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledWith({ + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }); + expect(order.mock.calls[0][0]).toHaveProperty('builder'); + expect(order.mock.calls[1][0]).toMatchObject({ + grouping: 'na', + builder: { + b: BUILDER_FEE_CONFIG.MainnetBuilder, + f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + }, + orders: [ + expect.objectContaining({ + p: '2450', + s: '0.4', + t: { + trigger: { + isMarket: true, + triggerPx: '2500', + tpsl: 'sl', + }, + }, + }), + ], + }); + }); + + it.each([ + { + label: 'resting', + statuses: [{ resting: { oid: 71 } }, { resting: { oid: 72 } }], + ids: ['71', '72'], + }, + { + label: 'filled', + statuses: [{ filled: { oid: 71 } }, { resting: { oid: 72 } }], + ids: ['71', '72'], + }, + { + label: 'waiting', + statuses: ['waitingForTrigger', { resting: { oid: 72 } }], + ids: undefined, + }, + { + label: 'duplicate', + statuses: [{ resting: { oid: 71 } }, { resting: { oid: 71 } }], + ids: undefined, + }, + { + label: 'zero', + statuses: [{ resting: { oid: 0 } }, { resting: { oid: 72 } }], + ids: undefined, + }, + ])( + 'returns only complete response-correlated TP/SL receipts: $label', + async ({ statuses, ids }) => { + useStrategyClients({ + info: { + orderStatus: jest.fn().mockResolvedValue({ status: 'unknownOid' }), + }, + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses } }, + }), + }, + }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + expect(result.success).toBe(true); + expect(result.childOrderIds).toStrictEqual(ids); + }, + ); + + it('returns an empty receipt for successful TP/SL removal', async () => { + useStrategyClients(); + expect( + await provider.updatePositionTPSL({ symbol: 'ETH' }), + ).toMatchObject({ success: true, childOrderIds: [] }); + }); + + it.each([ + 'matched', + 'marked', + 'wrong-cloid', + 'duplicate-id', + 'unknown', + 'read-failure', + 'response-mismatch', + 'wrong-coin', + ])('correlates final sent protection cloids: %s', async (mode) => { + let sent: { c?: string; t: { trigger?: { isMarket: boolean } } }[] = []; + const orderStatus = jest + .fn() + .mockImplementation(async ({ oid }: { oid: string }) => { + if (mode === 'read-failure') { + throw new Error('status unavailable'); + } + if (mode === 'unknown') { + return { status: 'unknownOid' }; + } + const index = sent.findIndex((order) => order.c === oid); + return { + status: 'order', + order: { + status: 'filled', + order: { + coin: mode === 'wrong-coin' ? 'BTC' : 'ETH', + cloid: + mode === 'wrong-cloid' + ? '0x11111111111111111111111111111111' + : oid, + oid: mode === 'duplicate-id' ? 777 : 777 + index, + }, + }, + }; + }); + useStrategyClients({ + exchange: { + order: jest + .fn() + .mockImplementation(async (request: { orders: typeof sent }) => { + sent = request.orders; + return { + status: 'ok', + response: { + data: { + statuses: + mode === 'response-mismatch' + ? [{ resting: { oid: 999 } }, 'waitingForTrigger'] + : ['waitingForTrigger', 'waitingForTrigger'], + }, + }, + }; + }), + }, + info: { orderStatus }, + }); + if (mode === 'marked') { + provider.setUserFeeResolution({ + feeBips: 0, + discountBips: 10000, + source: 'subscription', + subscription: { eligible: true, reason: 'eligible' }, + subscriptionWaiverKind: 'full', + }); + } + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + expect(result.success).toBe(true); + expect(result.childOrderIds).toStrictEqual( + mode === 'matched' || mode === 'marked' ? ['777', '778'] : undefined, + ); + expect(sent).toHaveLength(2); + expect(sent[0].c).toMatch(/^0x[0-9a-f]{32}$/u); + expect(sent[0].c).not.toBe(sent[1].c); + expect( + sent.map((order) => isSubscriptionProgramCloid(order.c)), + ).toStrictEqual([true, true]); + expect( + sent.map((order) => hasFeeReductionAppliedFlag(order.c)), + ).toStrictEqual([mode === 'marked', mode === 'marked']); + expect( + sent.map((order) => readSubscriptionCloidFlags(order.c)), + ).toStrictEqual( + mode === 'marked' + ? [ + SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied, + SUBSCRIPTION_CLOID_FLAGS.FeeReductionApplied, + ] + : [0, 0], + ); + expect(orderStatus).toHaveBeenCalledTimes(2); + expect(sent[0].t.trigger?.isMarket).toBe(false); + expect(sent[1].t.trigger?.isMarket).toBe(true); + expect(orderStatus).toHaveBeenCalledWith( + expect.objectContaining({ oid: sent[0].c }), + ); + }); + + it.each(['account', 'network', 'disconnect'])( + 'fences %s switching during receipt lookup', + async (change) => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['waitingForTrigger'] } }, + }), + }, + info: { + orderStatus: jest.fn().mockImplementation(async () => { + if (change === 'account') { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x2222222222222222222222222222222222222222', + ); + } else if (change === 'disconnect') { + await provider.disconnect(); + } else { + mockClientService.isTestnetMode.mockReturnValue(true); + } + return { status: 'unknownOid' }; + }), + }, + }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + stopLossPrice: '2500', + }); + expect(result.success).toBe(false); + expect(result.childOrderIds).toBeUndefined(); + }, + ); + + it('rejects expected entry drift during asynchronous order setup', async () => { + const { exchangeClient, infoClient } = useStrategyClients(); + infoClient.frontendOpenOrders.mockImplementation(async () => { + infoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { position: { coin: 'ETH', szi: '1.5', entryPx: '3001' } }, + ], + }); + return []; + }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + expectedPosition: { size: '1.5', entryPrice: '3000' }, + }); + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it.each([ + { size: '2', entryPrice: '3000' }, + { size: '-1.5', entryPrice: '3000' }, + { size: '1.5', entryPrice: '3001' }, + ])( + 'rejects a different expected protection position %j', + async (expectedPosition) => { + const { exchangeClient } = useStrategyClients(); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + expectedPosition, }); + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }, + ); + + it('accepts an unchanged expected position through protection setup', async () => { const { exchangeClient } = useStrategyClients({ - exchange: { order }, info: { - maxBuilderFee: jest - .fn() - .mockResolvedValueOnce(0) - .mockResolvedValueOnce(BUILDER_FEE_CONFIG.MaxFeeDecimal), - frontendOpenOrders: jest.fn().mockResolvedValue([ - { - coin: 'ETH', - side: 'A', - limitPx: '2450', - sz: '0.4', - origSz: '0.6', - oid: 456, - timestamp: 1_700_000_000_000, - reduceOnly: true, - isTrigger: true, - isPositionTpsl: false, - triggerCondition: 'Price below 2500', - triggerPx: '2500', - orderType: 'Stop Market', - children: [], - }, - ]), + orderStatus: jest.fn().mockResolvedValue({ status: 'unknownOid' }), }, }); - const result = await provider.updatePositionTPSL({ symbol: 'ETH', takeProfitPrice: '3500', + expectedPosition: { size: '1.50', entryPrice: '3000.0' }, }); - - expect(result).toMatchObject({ - success: false, - error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, - }); - expect(exchangeClient.approveBuilderFee).toHaveBeenCalledWith({ - builder: BUILDER_FEE_CONFIG.MainnetBuilder, - maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, - }); - expect(order.mock.calls[0][0]).toHaveProperty('builder'); - expect(order.mock.calls[1][0]).toMatchObject({ - grouping: 'na', - builder: { - b: BUILDER_FEE_CONFIG.MainnetBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }, - orders: [ - expect.objectContaining({ - p: '2450', - s: '0.4', - t: { - trigger: { - isMarket: true, - triggerPx: '2500', - tpsl: 'sl', - }, - }, - }), - ], - }); + expect(result.success).toBe(true); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); }); + it.each(['account', 'network', 'disconnect'])( + 'rejects %s switching during order setup before mutation', + async (change) => { + const { exchangeClient, infoClient } = useStrategyClients(); + infoClient.frontendOpenOrders.mockImplementation(async () => { + if (change === 'account') { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x2222222222222222222222222222222222222222', + ); + } else if (change === 'disconnect') { + await provider.disconnect(); + } else { + mockClientService.isTestnetMode.mockReturnValue(true); + } + return []; + }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + stopLossPrice: '2500', + }); + expect(result.success).toBe(false); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }, + ); + it('accepts an old TP/SL order that is already gone before replacement', async () => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -1636,6 +2810,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }), }, info: { + orderStatus: jest.fn().mockResolvedValue({ status: 'unknownOid' }), frontendOpenOrders: jest.fn().mockResolvedValue([ { coin: 'ETH', @@ -1658,6 +2833,7 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result).toStrictEqual({ success: true, orderId: 'TP/SL orders placed', + childOrderIds: ['123'], }); expect(exchangeClient.cancel.mock.invocationCallOrder[0]).toBeLessThan( exchangeClient.order.mock.invocationCallOrder[0], @@ -2090,7 +3266,18 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }), }, - info: { frontendOpenOrders }, + info: { + frontendOpenOrders, + orderStatus: jest + .fn() + .mockImplementation(async ({ oid }: { oid: string }) => ({ + status: 'order', + order: { + status: 'open', + order: { coin: 'ETH', cloid: oid, oid: 901 }, + }, + })), + }, }); const result = await provider.updatePositionTPSL({ @@ -2151,64 +3338,64 @@ describe('HyperLiquidProvider - strategy order types', () => { it.each([ ['an unknown placement status', ['futureTriggerStatus']], ['an incomplete placement response', []], - ])('restores old protection after %s', async (_label, statuses) => { - const order = jest - .fn() - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses }, - }, - }) - .mockResolvedValueOnce({ - status: 'ok', - response: { - data: { statuses: [{ resting: { oid: 902 } }] }, + ])( + 'retains uncertainty without duplicate restoration after %s', + async (_label, statuses) => { + const order = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses }, + }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: 902 } }] }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + info: { + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'A', + limitPx: '2450', + sz: '0', + origSz: '0', + oid: 456, + timestamp: 1_700_000_000_000, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + triggerCondition: 'Price below 2450', + triggerPx: '2450', + orderType: 'Stop Market', + children: [], + }, + ]), }, }); - const { exchangeClient } = useStrategyClients({ - exchange: { order }, - info: { - frontendOpenOrders: jest.fn().mockResolvedValue([ - { - coin: 'ETH', - side: 'A', - limitPx: '2450', - sz: '0', - origSz: '0', - oid: 456, - timestamp: 1_700_000_000_000, - reduceOnly: true, - isTrigger: true, - isPositionTpsl: true, - triggerCondition: 'Price below 2450', - triggerPx: '2450', - orderType: 'Stop Market', - children: [], - }, - ]), - }, - }); - const result = await provider.updatePositionTPSL({ - symbol: 'ETH', - takeProfitPrice: '3500', - position: createMockPosition({ symbol: 'ETH', size: '1.5' }), - }); + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + position: createMockPosition({ symbol: 'ETH', size: '1.5' }), + }); - expect(result).toStrictEqual({ - success: false, - error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, - }); - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [{ a: 1, o: 456 }], - }); - expect(order).toHaveBeenCalledTimes(2); - expect(order.mock.calls[1][0]).toMatchObject({ - grouping: 'positionTpsl', - orders: [expect.objectContaining({ p: '2450', s: '0' })], - }); - }); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 456 }], + }); + expect(order).toHaveBeenCalledTimes(1); + }, + ); it('cancels old protection before placing partial TP/SL', async () => { const { exchangeClient } = useStrategyClients({ @@ -7009,6 +8196,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(Object.isFrozen(capabilities)).toBe(true); @@ -7025,6 +8231,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); }); @@ -7038,6 +8263,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated'], }); expect(infoClient.meta).toHaveBeenCalledWith({ dex: 'xyz' }); @@ -7066,6 +8310,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated'], }); }, @@ -7151,6 +8414,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); }); @@ -7261,6 +8543,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); @@ -7277,6 +8578,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); @@ -7349,6 +8669,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); @@ -7363,6 +8702,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); @@ -7400,12 +8758,50 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }, { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }, ]); @@ -7496,6 +8892,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(1); @@ -7605,6 +9020,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(1); @@ -7655,6 +9089,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); @@ -7685,6 +9138,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); @@ -7722,6 +9194,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(2); @@ -7938,6 +9429,25 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ready', providerId: 'hyperliquid', supportedStrategies: ['twap', 'scale', 'chase'], + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_limit', + stopLossOrderType: 'stop_market', + defaultCoverage: 'dynamic-position', + partialCoverage: { + single: true, + pair: 'independent', + replacement: 'cancel-before-create', + recovery: 'provider-default', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], supportedMarginModes: ['isolated', 'cross'], }); expect(infoClient.meta).toHaveBeenCalledTimes(1); @@ -8003,6 +9513,21 @@ describe('HyperLiquidProvider - strategy order types', () => { count: 3, }; + it('leaves optional sizing absent while preserving normalized prices', async () => { + useStrategyClients(); + + const result = await provider.getScalePriceLadder({ + ...params, + sizing: { usdAmount: '60' }, + }); + + expect(result).toStrictEqual({ + status: 'ready', + providerId: 'hyperliquid', + prices: ['1234.6', '1234.7', '1234.8'], + }); + }); + it('normalizes every rung with provider-owned market precision', async () => { const { infoClient } = useStrategyClients(); diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts index 2a595ca94d4..186d3eb5def 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -112,12 +112,14 @@ function createBridge(): { type BuiltProvider = { provider: LighterProvider; + bridge: LighterSignerBridge; address: string; client: { sendTx: jest.Mock; getAccountsByL1Address: jest.Mock; getApiKeys: jest.Mock; getNextNonce: jest.Mock; + getTx: jest.Mock; }; accountSigner: { signPersonalMessage: jest.Mock }; call: jest.SpyInstance; @@ -136,6 +138,8 @@ type BuildOptions = { // Find the account by L1 address instead of a configured index. findAccountByAddress?: boolean; withoutBridge?: boolean; + storedKeyIndices?: number[]; + infrastructure?: ReturnType; }; function buildProvider({ @@ -144,6 +148,8 @@ function buildProvider({ keyringUnlocked = true, findAccountByAddress = false, withoutBridge = false, + storedKeyIndices, + infrastructure, }: BuildOptions = {}): BuiltProvider { const { address } = createMockEvmAccount(); const account = { @@ -183,9 +189,31 @@ function buildProvider({ ? createKeyringMessenger(MAIN_SIGNATURE, keyringUnlocked) : createKeyringlessMessenger(); const { bridge, calls } = createBridge(); + if (storedKeyIndices) { + Object.assign(bridge, { + getStoredKeyIndices: jest.fn().mockResolvedValue(storedKeyIndices), + }); + client.sendTx.mockImplementation(async () => { + const existing = (await client.getApiKeys()) as Awaited< + ReturnType + >; + const created = calls + .filter((signerCall) => signerCall.function === '_createClient') + .at(-1); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + ...existing.apiKeys, + { apiKeyIndex: created?.params[3], publicKey: '9c'.repeat(40) }, + ], + }); + return { code: 200, txHash: '0xsent' }; + }); + } + const baseInfrastructure = infrastructure ?? createMockInfrastructure(); const deps = keyring - ? createMockInfrastructure() - : { ...createMockInfrastructure(), accountSigner }; + ? baseInfrastructure + : { ...baseInfrastructure, accountSigner }; const provider = new LighterProvider({ isTestnet: true, platformDependencies: deps, @@ -199,6 +227,7 @@ function buildProvider({ }); return { provider, + bridge, address, client, accountSigner, @@ -212,6 +241,7 @@ function buildProvider({ describe('LighterProvider with accountSigner', () => { beforeEach(pinClock); + afterEach(() => jest.useRealTimers()); it('registers the venue key with an L1 signature from accountSigner', async () => { const { provider, address, client, accountSigner, call, calls } = @@ -434,6 +464,848 @@ describe('LighterProvider with accountSigner', () => { expect(client.sendTx).not.toHaveBeenCalled(); }); + it('recovers a restored wallet without overwriting its occupied slot', async () => { + const { provider, client, calls, accountSigner, bridge } = buildProvider({ + storedKeyIndices: [], + }); + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn( + async (params: { apiKeyIndices: number[] }) => params.apiKeyIndices, + ), + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }], + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + const created = calls + .filter((call) => call.function === '_createClient') + .at(-1); + expect(created?.params[3]).not.toBe(7); + expect(created?.params[3]).toBeGreaterThanOrEqual(2); + expect(created?.params[3]).toBeLessThanOrEqual(254); + expect(accountSigner.signPersonalMessage).toHaveBeenCalledTimes(1); + expect(client.sendTx).toHaveBeenCalledTimes(1); + }); + + it('reuses a matching device key in another slot without registering again', async () => { + const { provider, client, calls, accountSigner } = buildProvider({ + storedKeyIndices: [19], + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 19, publicKey: '9c'.repeat(40) }, + ], + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect( + calls.find((call) => call.function === '_createClient')?.params[3], + ).toBe(19); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + + it('reuses a wallet-recoverable registration after all local keys are lost', async () => { + const { provider, client, bridge, accountSigner, calls } = buildProvider({ + storedKeyIndices: [], + }); + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn().mockResolvedValue([7, 19]), + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 19, publicKey: '9c'.repeat(40) }, + ], + }); + + expect(await provider.prepareTradingWallet()).toStrictEqual({ + ready: true, + }); + expect( + calls + .filter((call) => call.function === '_createClient') + .map((call) => call.params[3]), + ).toStrictEqual([7, 19]); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + + it.each(['unresolved', 'quarantined'] as const)( + 'skips a nonmatching candidate with a %s nonce ledger', + async (ledgerState) => { + const { provider, client, bridge, accountSigner, calls, deps } = + buildProvider({ storedKeyIndices: [] }); + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn().mockResolvedValue([7, 19]), + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 19, publicKey: '9c'.repeat(40) }, + ], + }); + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ledger = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: + ledgerState === 'unresolved' + ? [ + { + nonce: NEXT_NONCE, + txHash: 'deadbeef', + expiresAt: NOW + TX_EXPIRY_MS, + kind: 14, + intent: 'order:ETH', + owner: null, + }, + ] + : [], + recovered: + ledgerState === 'quarantined' + ? [ + { + recoveryId: '42:deadbeef', + kind: 14, + intent: 'order:ETH', + txHash: 'deadbeef', + outcome: 'unknown', + evidence: 'rest-advance', + }, + ] + : [], + }); + await deps.diskCache.setItem(ledgerKey, ledger); + + expect(await provider.prepareTradingWallet()).toStrictEqual({ + ready: true, + }); + + expect( + calls + .filter((call) => call.function === '_createClient') + .map((call) => call.params[3]), + ).toStrictEqual([7, 19]); + expect(await deps.diskCache.getItem(ledgerKey)).toBe(ledger); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + }, + ); + + it.each(['unresolved', 'quarantined'] as const)( + 'retains the %s nonce guard on a matching candidate', + async (ledgerState) => { + const { provider, client, bridge, calls, deps } = buildProvider({ + storedKeyIndices: [], + }); + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn().mockResolvedValue([7, 19]), + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 7, publicKey: '9c'.repeat(40) }, + { apiKeyIndex: 19, publicKey: '9c'.repeat(40) }, + ], + }); + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ledger = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: + ledgerState === 'unresolved' + ? [ + { + nonce: NEXT_NONCE, + txHash: 'deadbeef', + expiresAt: NOW + TX_EXPIRY_MS, + kind: 14, + intent: 'order:ETH', + owner: null, + }, + ] + : [], + recovered: + ledgerState === 'quarantined' + ? [ + { + recoveryId: '42:deadbeef', + kind: 14, + intent: 'order:ETH', + txHash: 'deadbeef', + outcome: 'unknown', + evidence: 'rest-advance', + }, + ] + : [], + }); + await deps.diskCache.setItem(ledgerKey, ledger); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(result.error).toMatch(/unresolved|UNKNOWN/u); + expect( + calls + .filter((call) => call.function === '_createClient') + .map((call) => call.params[3]), + ).not.toContain(19); + expect(await deps.diskCache.getItem(ledgerKey)).toBe(ledger); + expect(client.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('recreates registration plaintext when the durable nonce floor exceeds the probe seed', async () => { + const { provider, client, bridge, calls, deps, accountSigner, address } = + buildProvider({ storedKeyIndices: [] }); + jest.spyOn(bridge, 'createClient').mockImplementation(async (params) => ({ + ...(await bridge.execute({ + function: '_createClient', + params: [ + params.chainId, + params.accountIndex, + params.nonce, + params.apiKeyIndex, + ], + })), + body: `registration nonce=${String(params.nonce)}`, + })); + await deps.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: NEXT_NONCE + 1, + entries: [], + recovered: [], + }), + ); + + expect(await provider.prepareTradingWallet()).toStrictEqual({ + ready: true, + }); + + expect( + calls + .filter((call) => call.function === '_createClient') + .map((call) => call.params[2]), + ).toStrictEqual([NEXT_NONCE, NEXT_NONCE + 1]); + expect(accountSigner.signPersonalMessage).toHaveBeenCalledWith( + address, + `registration nonce=${String(NEXT_NONCE + 1)}`, + ); + expect( + calls.find((call) => call.function === '_signChangePubKey')?.params[2], + ).toBe(NEXT_NONCE + 1); + expect(client.sendTx).toHaveBeenCalledTimes(1); + }); + + it('refuses a changed key during nonce-adjusted registration preparation', async () => { + const { provider, client, bridge, deps, accountSigner } = buildProvider({ + storedKeyIndices: [], + }); + jest.spyOn(bridge, 'createClient').mockImplementation(async (params) => ({ + ...(await bridge.execute({ + function: '_createClient', + params: [ + params.chainId, + params.accountIndex, + params.nonce, + params.apiKeyIndex, + ], + })), + pk: params.nonce === NEXT_NONCE ? '9c'.repeat(40) : 'ab'.repeat(40), + })); + await deps.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: NEXT_NONCE + 1, + entries: [], + recovered: [], + }), + ); + + expect(await provider.prepareTradingWallet()).toStrictEqual({ + ready: false, + error: 'Lighter signer identity changed during registration preparation', + }); + + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + + it.each(['wallet-recovery', 'storage-only'] as const)( + 'refuses repeated slot allocation with %s discovery after key loss', + async (discovery) => { + const { provider, client, bridge, calls } = buildProvider({ + storedKeyIndices: [], + }); + if (discovery === 'wallet-recovery') { + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn().mockResolvedValue([]), + }); + } + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }], + }); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('No recoverable Lighter trading key'); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(calls).toStrictEqual([]); + }, + ); + + it('reuses an occupied recoverable slot before a free preferred slot', async () => { + const { provider, client, bridge, calls } = buildProvider({ + storedKeyIndices: [], + }); + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn().mockResolvedValue([7, 19]), + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 19, publicKey: '9c'.repeat(40) }], + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect( + calls + .filter((call) => call.function === '_createClient') + .map((call) => call.params[3]), + ).toStrictEqual([19]); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + + it('binds bridge discovery and client creation to the Core wallet address', async () => { + const { provider, bridge, address } = buildProvider({ + storedKeyIndices: [], + }); + const discover = jest.fn().mockResolvedValue([]); + Object.assign(bridge, { getRecoverableKeyIndices: discover }); + const createClient = jest.spyOn(bridge, 'createClient'); + + await provider.prepareTradingWallet(); + + expect(discover).toHaveBeenCalledWith( + expect.objectContaining({ walletAddress: address.toLowerCase() }), + ); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ walletAddress: address.toLowerCase() }), + ); + }); + + it('refuses unrequested recoverable slots before creating a client', async () => { + const { provider, client, bridge, calls } = buildProvider({ + storedKeyIndices: [], + }); + Object.assign(bridge, { + getRecoverableKeyIndices: jest.fn().mockResolvedValue([255]), + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toMatchObject({ + ready: false, + error: 'Lighter signer returned an unrequested key slot', + }); + expect(calls).toStrictEqual([]); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + + it('waits for the registered public key before reporting trading ready', async () => { + jest.useFakeTimers(); + const { provider, client } = buildProvider({ storedKeyIndices: [] }); + client.sendTx.mockImplementationOnce(async () => { + client.getApiKeys + .mockResolvedValueOnce({ code: 200, apiKeys: [] }) + .mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 7, publicKey: '9c'.repeat(40) }], + }); + return { code: 200, txHash: '0xsent' }; + }); + let ready = false; + const setup = provider.prepareTradingWallet().then((result) => { + ready = result.ready; + return result; + }); + await jest.advanceTimersByTimeAsync(0); + + expect(ready).toBe(false); + await jest.advanceTimersByTimeAsync(1000); + expect(await setup).toStrictEqual({ ready: true }); + expect(client.sendTx).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('skips a replaced local key and registers only in an unused slot', async () => { + const { provider, client, calls } = buildProvider({ + storedKeyIndices: [7], + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }], + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + const creates = calls.filter((call) => call.function === '_createClient'); + expect(creates[0].params[3]).toBe(7); + expect(creates[1].params[3]).not.toBe(7); + expect(client.sendTx).toHaveBeenCalledTimes(1); + }); + + it('keeps an invisible accepted registration on its original slot after provider restart', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + const first = buildProvider({ storedKeyIndices: [7] }); + const occupied = [{ apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }]; + first.client.getApiKeys.mockResolvedValue({ code: 200, apiKeys: occupied }); + first.client.sendTx.mockResolvedValue({ code: 200, txHash: '0xsent' }); + const original = first.provider.prepareTradingWallet(); + await jest.advanceTimersByTimeAsync(11000); + const pendingResult1 = await original; + expect(pendingResult1.ready).toBe(false); + expect(pendingResult1.error).toContain('registration is still pending'); + const registration = first.calls.find( + (call) => call.function === '_signChangePubKey', + ); + if (!registration) { + throw new Error('Expected accepted registration'); + } + const originalSlot = Number(registration.params[3]); + expect(originalSlot).not.toBe(7); + expect(first.client.sendTx).toHaveBeenCalledTimes(1); + const pendingKey = `lighterKeyRegistration:testnet:${first.address.toLowerCase()}:28`; + const pendingBytes = await first.deps.diskCache.getItem(pendingKey); + expect(JSON.parse(pendingBytes ?? '')).toMatchObject({ + apiKeyIndex: originalSlot, + accepted: true, + txHash: 'dddd000000000001', + nonce: 42, + }); + await first.provider.disconnect(); + + jest.spyOn(Math, 'random').mockReturnValue(0.5); + const restarted = buildProvider({ + storedKeyIndices: [7], + infrastructure: first.deps, + }); + Object.assign(restarted.bridge, { + getStoredKeyIndices: jest.fn( + async ({ apiKeyIndices }: { apiKeyIndices: number[] }) => + [7, originalSlot].filter((slot) => apiKeyIndices.includes(slot)), + ), + }); + restarted.client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: occupied, + }); + restarted.client.sendTx.mockResolvedValue({ code: 200, txHash: '0xsent' }); + const retry = restarted.provider.prepareTradingWallet(); + await jest.advanceTimersByTimeAsync(11000); + const pendingResult2 = await retry; + expect(pendingResult2.ready).toBe(false); + expect(pendingResult2.error).toContain('registration is still pending'); + expect(restarted.client.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.filter((call) => call.function === '_signChangePubKey'), + ).toStrictEqual([]); + + jest.spyOn(Date, 'now').mockReturnValue(NOW + TX_EXPIRY_MS + 30001); + const pendingResult3 = await restarted.provider.prepareTradingWallet(); + expect(pendingResult3.ready).toBe(false); + expect(pendingResult3.error).toContain('registration is still pending'); + expect(await first.deps.diskCache.getItem(pendingKey)).toBe(pendingBytes); + expect(restarted.client.sendTx).not.toHaveBeenCalled(); + + restarted.client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + ...occupied, + { apiKeyIndex: originalSlot, publicKey: '9c'.repeat(40) }, + ], + }); + expect(await restarted.provider.prepareTradingWallet()).toStrictEqual({ + ready: true, + }); + expect( + jest.mocked(restarted.bridge).createClient.mock.lastCall, + ).toStrictEqual([expect.objectContaining({ apiKeyIndex: originalSlot })]); + expect(restarted.client.sendTx).not.toHaveBeenCalled(); + expect(restarted.accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(await first.deps.diskCache.getItem(pendingKey)).toBeNull(); + await restarted.provider.disconnect(); + }); + + it.each(['pending-storage', 'ledger-storage'] as const)( + 'does not dispatch a registration when %s fails', + async (failure) => { + const built = buildProvider({ storedKeyIndices: [] }); + const setItem = jest + .spyOn(built.deps.diskCache, 'setItem') + .getMockImplementation(); + if (!setItem) { + throw new Error('Missing storage writer'); + } + jest + .spyOn(built.deps.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + if ( + key.startsWith( + failure === 'pending-storage' + ? 'lighterKeyRegistration:' + : 'lighterNonceLedger:', + ) + ) { + throw new Error('Storage unavailable'); + } + return setItem(key, value); + }); + expect(await built.provider.prepareTradingWallet()).toMatchObject({ + ready: false, + }); + expect(built.client.sendTx).not.toHaveBeenCalled(); + expect( + await built.deps.diskCache.getItem( + `lighterKeyRegistration:testnet:${built.address.toLowerCase()}:28`, + ), + ).toBeNull(); + await built.provider.disconnect(); + }, + ); + + it.each([ + 'failed', + 'expired-absent', + 'pending', + 'executed', + 'foreign', + ] as const)( + 'reconciles an uncertain registration through exact %s evidence before retry', + async (outcome) => { + const first = buildProvider({ storedKeyIndices: [] }); + first.client.sendTx.mockRejectedValue(new Error('Response lost')); + expect(await first.provider.prepareTradingWallet()).toMatchObject({ + ready: false, + }); + const pendingKey = `lighterKeyRegistration:testnet:${first.address.toLowerCase()}:28`; + const before = await first.deps.diskCache.getItem(pendingKey); + expect(JSON.parse(before ?? '')).toMatchObject({ + accepted: false, + apiKeyIndex: 7, + }); + await first.provider.disconnect(); + const restarted = buildProvider({ + storedKeyIndices: [7], + infrastructure: first.deps, + }); + restarted.client.getTx.mockResolvedValue( + outcome === 'expired-absent' + ? null + : { + code: 200, + hash: 'dddd000000000001', + accountIndex: outcome === 'foreign' ? 29 : 28, + apiKeyIndex: 7, + nonce: 42, + status: { + failed: 0, + executed: 2, + pending: 1, + foreign: 1, + 'expired-absent': 1, + }[outcome], + }, + ); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TX_EXPIRY_MS + 30001); + const retry = await restarted.provider.prepareTradingWallet(); + const canRetry = outcome === 'failed' || outcome === 'expired-absent'; + expect(retry.ready).toBe(canRetry); + expect(restarted.client.sendTx).toHaveBeenCalledTimes(canRetry ? 1 : 0); + const expectedBytes = + outcome === 'executed' + ? JSON.stringify({ + ...(JSON.parse(before ?? '') as Record), + accepted: true, + }) + : before; + expect(await first.deps.diskCache.getItem(pendingKey)).toBe( + canRetry ? null : expectedBytes, + ); + expect( + restarted.calls.filter((call) => call.function === '_signChangePubKey'), + ).toHaveLength(canRetry ? 1 : 0); + await restarted.provider.disconnect(); + }, + ); + + it('retains observed execution before an expired absent lookup across another restart', async () => { + const first = buildProvider({ storedKeyIndices: [] }); + first.client.sendTx.mockRejectedValue(new Error('Response lost')); + expect(await first.provider.prepareTradingWallet()).toMatchObject({ + ready: false, + }); + const pendingKey = `lighterKeyRegistration:testnet:${first.address.toLowerCase()}:28`; + await first.provider.disconnect(); + const observed = buildProvider({ + storedKeyIndices: [7], + infrastructure: first.deps, + }); + observed.client.getTx.mockResolvedValue({ + code: 200, + hash: 'dddd000000000001', + accountIndex: 28, + apiKeyIndex: 7, + nonce: 42, + status: 2, + }); + expect(await observed.provider.prepareTradingWallet()).toMatchObject({ + ready: false, + }); + expect(observed.client.sendTx).not.toHaveBeenCalled(); + expect( + JSON.parse((await first.deps.diskCache.getItem(pendingKey)) ?? ''), + ).toMatchObject({ accepted: true }); + await observed.provider.disconnect(); + jest.spyOn(Date, 'now').mockReturnValue(NOW + TX_EXPIRY_MS + 30001); + const absent = buildProvider({ + storedKeyIndices: [7], + infrastructure: first.deps, + }); + absent.client.getTx.mockResolvedValue(null); + expect(await absent.provider.prepareTradingWallet()).toMatchObject({ + ready: false, + }); + expect(absent.client.sendTx).not.toHaveBeenCalled(); + expect( + absent.calls.filter((call) => call.function === '_signChangePubKey'), + ).toStrictEqual([]); + expect( + JSON.parse((await first.deps.diskCache.getItem(pendingKey)) ?? ''), + ).toMatchObject({ + accepted: true, + apiKeyIndex: 7, + txHash: 'dddd000000000001', + nonce: 42, + }); + await absent.provider.disconnect(); + }); + + it.each(['hash', 'expiry'] as const)( + 'preserves the existing legacy dispatch refusal for omitted %s and the discovery identity guard', + async (missing) => { + const legacy = buildProvider(); + const discovery = buildProvider({ storedKeyIndices: [] }); + for (const built of [legacy, discovery]) { + const execute = jest.spyOn(built.bridge, 'execute'); + const sign = execute.getMockImplementation(); + if (!sign) { + throw new Error('Missing signer fixture'); + } + execute.mockImplementation( + async ( + call: LighterWasmCall, + ): Promise> => { + if (call.function === '_signChangePubKey') { + return { + txInfo: JSON.stringify({ + changePubKey: true, + Nonce: NEXT_NONCE, + ...(missing === 'expiry' + ? {} + : { ExpiredAt: NOW + TX_EXPIRY_MS }), + }), + ...(missing === 'hash' ? {} : { txHash: 'dddd000000000001' }), + } as LighterSignerResult; + } + return sign(call); + }, + ); + } + expect(await legacy.provider.prepareTradingWallet()).toStrictEqual({ + ready: false, + error: + 'Lighter dispatch refused: the signing result did not provide a complete transaction identity (hash + expiry)', + }); + expect(legacy.client.sendTx).not.toHaveBeenCalled(); + expect(await discovery.provider.prepareTradingWallet()).toMatchObject({ + ready: false, + }); + expect(discovery.client.sendTx).not.toHaveBeenCalled(); + await legacy.provider.disconnect(); + await discovery.provider.disconnect(); + }, + ); + + it('preserves pending registration bytes when its local signer key changes', async () => { + jest.useFakeTimers(); + const first = buildProvider({ storedKeyIndices: [] }); + first.client.sendTx.mockResolvedValue({ code: 200, txHash: '0xsent' }); + const setup = first.provider.prepareTradingWallet(); + await jest.advanceTimersByTimeAsync(11000); + expect(await setup).toMatchObject({ ready: false }); + const key = `lighterKeyRegistration:testnet:${first.address.toLowerCase()}:28`; + const before = await first.deps.diskCache.getItem(key); + await first.provider.disconnect(); + const restarted = buildProvider({ + storedKeyIndices: [7], + infrastructure: first.deps, + }); + jest.spyOn(restarted.bridge, 'createClient').mockResolvedValue({ + success: true, + pk: 'ab'.repeat(40), + pubKeySuccess: true, + body: CHANGE_PUB_KEY_BODY, + }); + const pendingResult4 = await restarted.provider.prepareTradingWallet(); + expect(pendingResult4.ready).toBe(false); + expect(pendingResult4.error).toContain('different local key'); + expect(await first.deps.diskCache.getItem(key)).toBe(before); + expect(restarted.client.sendTx).not.toHaveBeenCalled(); + await restarted.provider.disconnect(); + }); + + it('serializes concurrent provider setup around an invisible accepted registration', async () => { + jest.useFakeTimers(); + const first = buildProvider({ storedKeyIndices: [] }); + first.client.sendTx.mockResolvedValue({ code: 200, txHash: '0xsent' }); + const second = buildProvider({ + storedKeyIndices: [], + infrastructure: first.deps, + }); + second.client.sendTx.mockResolvedValue({ code: 200, txHash: '0xsent' }); + const a = first.provider.prepareTradingWallet(); + const b = second.provider.prepareTradingWallet(); + await jest.advanceTimersByTimeAsync(11000); + const pendingResult5 = await a; + expect(pendingResult5.ready).toBe(false); + expect(pendingResult5.error).toContain('registration is still pending'); + const pendingResult6 = await b; + expect(pendingResult6.ready).toBe(false); + expect(pendingResult6.error).toContain('registration is still pending'); + expect( + first.client.sendTx.mock.calls.length + + second.client.sendTx.mock.calls.length, + ).toBe(1); + expect( + [...first.calls, ...second.calls].filter( + (call) => call.function === '_signChangePubKey', + ), + ).toHaveLength(1); + await first.provider.disconnect(); + await second.provider.disconnect(); + }); + + it('stops waiting when accepted key registration remains invisible', async () => { + jest.useFakeTimers(); + const { provider, client } = buildProvider({ storedKeyIndices: [] }); + client.sendTx.mockResolvedValue({ code: 200, txHash: '0xsent' }); + const setup = provider.prepareTradingWallet(); + + await jest.advanceTimersByTimeAsync(11000); + + const result = await setup; + expect(result.ready).toBe(false); + expect(result.error).toContain('registration is still pending'); + expect(client.sendTx).toHaveBeenCalledTimes(1); + }); + + it('refuses a different key becoming visible during registration', async () => { + const { provider, client } = buildProvider({ storedKeyIndices: [] }); + client.sendTx.mockImplementation(async () => { + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }], + }); + return { code: 200, txHash: '0xsent' }; + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toMatchObject({ + ready: false, + error: 'Lighter trading key changed during registration', + }); + expect(client.sendTx).toHaveBeenCalledTimes(1); + }); + + it('bounds a stalled registration-visibility read by the wall deadline', async () => { + jest.useFakeTimers(); + const { provider, client } = buildProvider({ storedKeyIndices: [] }); + client.sendTx.mockImplementation(async () => { + client.getApiKeys.mockImplementation(() => new Promise(() => undefined)); + return { code: 200, txHash: '0xsent' }; + }); + const setup = provider.prepareTradingWallet(); + + await jest.advanceTimersByTimeAsync(11000); + const result = await setup; + + expect(result.ready).toBe(false); + expect(result.error).toContain('registration is still pending'); + expect(client.sendTx).toHaveBeenCalledTimes(1); + expect(client.getApiKeys).toHaveBeenCalledTimes(3); + }); + + it('abandons registration visibility when the selected wallet changes', async () => { + const { provider, client, selectAccount } = buildProvider({ + storedKeyIndices: [], + }); + client.sendTx.mockImplementation(async () => { + client.getApiKeys.mockImplementation(async () => { + selectAccount(OTHER_MAIN_ADDRESS); + return { code: 200, apiKeys: [] }; + }); + return { code: 200, txHash: '0xsent' }; + }); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + expect(client.sendTx).toHaveBeenCalledTimes(1); + }); + + it('fails without signing or replacing any key when every slot is occupied', async () => { + const { provider, client, accountSigner, calls } = buildProvider({ + storedKeyIndices: [], + }); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: Array.from({ length: 253 }, (_, index) => ({ + apiKeyIndex: index + 2, + publicKey: 'ab'.repeat(40), + })), + }); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('No available Lighter trading key slot'); + expect(calls).toStrictEqual([]); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + it('reports NO_ACCOUNT_SELECTED without registering or logging when no account is selected', async () => { const { provider, accountSigner, client, calls, deps, deselectAccount } = buildProvider(); diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index bee1c363327..17222b8bcf7 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -1,23 +1,69 @@ import { webcrypto } from 'crypto'; +import { + LIGHTER_TX_TYPE_CREATE_ORDER, + LIGHTER_TX_TYPE_CANCEL_ORDER, + LIGHTER_TRANSACTION_STATUS, + LIGHTER_TX_EXPIRY_SLACK_MS, + LIGHTER_NATIVE_PROBE_CANCEL_LIMIT, + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS, + LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + LIGHTER_ORDER_EXPIRY_NONE, +} from '../../../src/constants/lighterConfig.js'; +import { + PerpsController, + InitializationState, +} from '../../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { AggregatedPerpsProvider } from '../../../src/providers/AggregatedPerpsProvider.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; import { LighterProvider } from '../../../src/providers/LighterProvider.js'; +import type { LighterChaseRecord } from '../../../src/services/LighterChaseService.js'; +import { LighterChaseService } from '../../../src/services/LighterChaseService.js'; import { LighterApiError, LighterClientService, } from '../../../src/services/LighterClientService.js'; import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import { TradingService } from '../../../src/services/TradingService.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; +import type { + CandleData, + Order, + OrderBookData, + OrderFill, + OrderParams, + PerpsProvider, + PerpsProviderType, + GetChaseOrderOwnershipParams, + ReconcileChaseOrderCancellationParams, + PerpsActiveProviderMode, +} from '../../../src/types/index.js'; import type { LighterSignerBridge, + LighterApiOrder, + LighterEditableOrder, LighterSignerOperation, LighterSignerResult, LighterWasmCall, LighterWebSocketCtor, LighterWebSocketLike, } from '../../../src/types/lighter-types.js'; -import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; +import type { LighterAttachedGroup } from '../../../src/utils/lighterAttachedOrders.js'; +import { parseLighterScaleGroups } from '../../../src/utils/lighterScaleOrders.js'; +import { + createMockInfrastructure, + createMockServiceContext, + createDeferred, + createKeyringlessMessenger, + keyringCalls, +} from '../../helpers/serviceMocks.js'; jest.mock('../../../src/services/LighterClientService', () => ({ - ...jest.requireActual('../../../src/services/LighterClientService'), + ...jest.requireActual< + typeof import('../../../src/services/LighterClientService') + >('../../../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(), @@ -207,12 +253,36 @@ function createMockBridge(): MockBridgeBundle { return { txInfo: JSON.stringify({ createOrder: true, + ClientOrderIndex: Number(call.params[2]), + OrderExpiry: + Number(call.params[10]) === -1 + ? Date.now() + 28 * 24 * 60 * 60 * 1000 + : Number(call.params[10]), Nonce: Number((call.params as (string | number)[]).at(-1)), ExpiredAt: Date.now() + 599_000, }), txHash: createHash, } as LighterSignerResult; } + case '_signModifyOrder': { + signSequence += 1; + return { + txInfo: JSON.stringify({ + AccountIndex: call.params[0], + ApiKeyIndex: 7, + MarketIndex: call.params[1], + Index: String(call.params[2]), + BaseAmount: call.params[3], + Price: call.params[4], + TriggerPrice: call.params[5], + Nonce: call.params[6], + ExpiredAt: Date.now() + 599_000, + Sig: `${'A'.repeat(107)}=`, + L2TxAttributes: null, + }).replace(/"Index":"(\d+)"/u, '"Index":$1'), + txHash: `dddd${String(signSequence).padStart(12, '0')}`, + } as LighterSignerResult; + } case '_signCancelOrder': { signSequence += 1; const cancelHash = `bbbb${String(signSequence).padStart(12, '0')}`; @@ -239,9 +309,20 @@ function createMockBridge(): MockBridgeBundle { case '_signCreateGroupedOrders': { signSequence += 1; const groupedHash = `cccc${String(signSequence).padStart(12, '0')}`; + const groupedDefaultExpiry = Date.now() + 28 * 24 * 60 * 60 * 1000; return { txInfo: JSON.stringify({ createGroupedOrders: true, + Orders: Array.from( + { length: Number(call.params[2]) }, + (_, index) => ({ + ClientOrderIndex: Number(call.params[4 + index * 10]), + OrderExpiry: + Number(call.params[12 + index * 10]) === -1 + ? groupedDefaultExpiry + : Number(call.params[12 + index * 10]), + }), + ), Nonce: Number((call.params as (string | number)[]).at(-1)), ExpiredAt: Date.now() + 599_000, }), @@ -291,8 +372,10 @@ function createMockBridge(): MockBridgeBundle { } type MockClientInstance = { + getEditableOrders: jest.Mock; network: string; getCandles: jest.Mock; + getOrderBookOrders: jest.Mock; getOrderBooks: jest.Mock; getOrderBookDetails: jest.Mock; getAccountsByL1Address: jest.Mock; @@ -300,6 +383,7 @@ type MockClientInstance = { getApiKeys: jest.Mock; getNextNonce: jest.Mock; getActiveOrders: jest.Mock; + getOrdersByClientIds: jest.Mock; getInactiveOrders: jest.Mock; getTx: jest.Mock; getDepositHistory: jest.Mock; @@ -337,6 +421,8 @@ type BuiltProvider = { */ function buildProvider( options: { + chaseTestnetProbe?: boolean; + nativeTwapTestnetProbe?: boolean; withBridge?: boolean; registeredKey?: string; webSocketCtor?: LighterWebSocketCtor | null; @@ -365,8 +451,41 @@ function buildProvider( sharedBridge, } = options; const clientInstance = { + getEditableOrders: jest.fn().mockResolvedValue({ code: 200, orders: [] }), + getOrdersByClientIds: jest + .fn() + .mockResolvedValue({ code: 200, orders: [] }), network: 'testnet', getCandles: jest.fn().mockResolvedValue({ code: 200, c: [] }), + getOrderBookOrders: jest.fn().mockResolvedValue({ + code: 200, + totalBids: 1, + totalAsks: 1, + bids: [ + { + orderId: '101', + orderIndex: 101, + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '99999', + orderExpiry: 0, + transactionTime: 1, + }, + ], + asks: [ + { + orderId: '102', + orderIndex: 102, + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '100001', + orderExpiry: 0, + transactionTime: 1, + }, + ], + }), getOrderBooks: jest.fn().mockResolvedValue([BTC_MARKET]), getOrderBookDetails: jest.fn().mockResolvedValue({ code: 200, @@ -530,6 +649,8 @@ function buildProvider( const { bridge, calls, fireReset, resetListenerCount, resetUnsubscribe } = bridgeBundle; const provider = new LighterProvider({ + nativeTwapTestnetProbe: options.nativeTwapTestnetProbe, + chaseTestnetProbe: options.chaseTestnetProbe, isTestnet, platformDependencies, lighterAuthConfig: { @@ -595,3461 +716,19705 @@ class StreamFakeWebSocket implements LighterWebSocketLike { const fakeStreamCtor = StreamFakeWebSocket as unknown as LighterWebSocketCtor; -describe('LighterProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); +/** Real public controller with initialized providers and host-controlled selection. */ +class ChaseOwnershipController extends PerpsController { + /** + * @param provider - Actual provider under test. + * @param mode - Host route. + */ + activate( + provider: PerpsProvider, + mode: PerpsActiveProviderMode = 'lighter', + ): void { + this.activeProviderInstance = provider; + if (mode !== 'aggregated') { + this.providers.set(mode, provider); + } + this.isInitialized = true; + this.update((state) => { + state.activeProvider = mode; + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * @param change - Host context changes during an asynchronous read. + */ + changeContext(change: { + isTestnet?: boolean; + activeProvider?: PerpsActiveProviderMode; + }): void { + this.update((state) => Object.assign(state, change)); + } +} + +/** + * @param provider - Real provider, with only external I/O mocked. + * @param mode - Active route. + * @returns Real controller and its registered public messenger. + */ +function ownershipController( + provider: PerpsProvider, + mode: PerpsActiveProviderMode = 'lighter', +): { + controller: ChaseOwnershipController; + host: ReturnType; +} { + const host = createKeyringlessMessenger(); + host.selectAccount(ACCOUNT.l1Address as `0x${string}`); + host.rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ remoteFeatureFlags: {}, cacheTimestamp: 0 }), + ); + host.rootMessenger.delegate({ + actions: ['RemoteFeatureFlagController:getState'], + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger: host.messenger, }); + const controller = new ChaseOwnershipController({ + messenger: host.messenger, + state: { isTestnet: true }, + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + controller.activate(provider, mode); + return { controller, host }; +} - describe('lifecycle', () => { - it('exposes the lighter protocol id', () => { - const { provider } = buildProvider(); - expect(provider.protocolId).toBe('lighter'); +describe('LighterProvider', () => { + describe('native ordinary resting-limit edit', () => { + const target = (): LighterEditableOrder => ({ + orderIndex: '288230376151711745', + orderId: '288230376151711745', + clientOrderIndex: 1, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + filledBaseAmount: '0', + price: '90000', + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000, }); + const edit = { + orderId: '288230376151711745', + newOrder: { + symbol: 'BTC', + orderType: 'limit' as const, + isBuy: true, + size: '0.001', + price: '91000', + }, + }; + const executed = { + code: 200, + hash: 'dddd000000000001', + accountIndex: 28, + apiKeyIndex: 7, + nonce: 42, + status: 2, + }; + const journalKey = `lighterNativeEdit:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, edit.orderId])}`; - 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); - }); + describe('bounded native edit settlement polling', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); - 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' }); - }); + function setup(): BuiltProvider & { + deps: ReturnType; + setRow: (next: LighterEditableOrder) => void; + } { + const deps = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + let row = target(); + built.clientInstance.getEditableOrders.mockImplementation(async () => ({ + code: 200, + orders: [row], + })); + built.clientInstance.sendTx.mockImplementation(async () => { + row = { ...row, price: '91000' }; + return { code: 200 }; + }); + return { + ...built, + deps, + setRow: (next: LighterEditableOrder): void => { + row = next; + }, + }; + } - it('disconnects cleanly', async () => { - const { provider, resetListenerCount, resetUnsubscribe } = - buildProvider(); - expect(resetListenerCount()).toBe(1); - expect(await provider.disconnect()).toStrictEqual({ - success: true, + it('waits for pending then executed and returns settled after one signed send', async () => { + const built = setup(); + built.clientInstance.getTx + .mockResolvedValueOnce({ ...executed, status: 1 }) + .mockResolvedValue(executed); + + const pending = built.provider.editOrder(edit); + await jest.advanceTimersByTimeAsync(3000); + const result = await pending; + + expect(result).toMatchObject({ + success: true, + orderId: edit.orderId, + orderEdit: { status: 'settled', observation: { price: '91000' } }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.getTx).toHaveBeenNthCalledWith( + 1, + executed.hash, + ); + expect(built.clientInstance.getTx).toHaveBeenNthCalledWith( + 2, + executed.hash, + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect( + built.calls.filter((call) => call.function === '_signModifyOrder'), + ).toHaveLength(1); }); - expect(resetUnsubscribe).toHaveBeenCalledTimes(1); - expect(resetListenerCount()).toBe(0); - }); - it('does not rebind when a delayed account lookup resolves after disconnect', async () => { - const { provider, clientInstance, calls, resetListenerCount } = - buildProvider(); - let resolveAccount: (value: unknown) => void = () => undefined; - let signalAccountLookup: () => void = () => undefined; - const accountLookupStarted = new Promise((resolve) => { - signalAccountLookup = resolve; + it('waits for exact order visibility after the transaction executes', async () => { + const built = setup(); + built.clientInstance.getTx + .mockImplementationOnce(async () => { + built.setRow(target()); + return executed; + }) + .mockImplementation(async () => { + built.setRow({ ...target(), price: '91000' }); + return executed; + }); + + const pending = built.provider.editOrder(edit); + await jest.advanceTimersByTimeAsync(3000); + + expect(await pending).toMatchObject({ + success: true, + orderEdit: { status: 'settled' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); }); - clientInstance.getAccountByIndex - .mockImplementationOnce(() => { - signalAccountLookup(); - return new Promise((resolve) => { - resolveAccount = resolve; + + it.each(['pending', 'missing', 'lookup-error'] as const)( + 'bounds %s settlement and keeps later reconciliation read-only', + async (mode) => { + const built = setup(); + built.clientInstance.getTx.mockImplementation(async () => { + if (mode === 'lookup-error') { + throw new Error('lookup unavailable'); + } + return mode === 'missing' ? null : { ...executed, status: 1 }; }); - }) - .mockResolvedValue({ code: 200, accounts: [ACCOUNT] }); - provider.subscribeToAccount({ callback: jest.fn() }); - await accountLookupStarted; - await provider.disconnect(); - expect(resetListenerCount()).toBe(0); - resolveAccount({ code: 200, accounts: [ACCOUNT] }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); + const pending = built.provider.editOrder(edit); + await jest.advanceTimersByTimeAsync(3000); - expect(resetListenerCount()).toBe(0); - expect( - calls.filter((call) => call.function === '_createClient'), - ).toHaveLength(0); - }); + expect(await pending).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(10); + expect( + JSON.parse((await built.deps.diskCache.getItem(journalKey)) ?? ''), + ).toMatchObject({ + phase: 'accepted', + status: 'pending', + txHash: executed.hash, + nonce: 42, + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(11); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect( + built.calls.filter((call) => call.function === '_signModifyOrder'), + ).toHaveLength(1); + }, + ); - it('does not recreate the signer when a delayed auth mint resolves after disconnect', async () => { - const { provider, bridge, calls, resetListenerCount } = buildProvider({ - registeredKey: '9c'.repeat(40), - }); - const realExecute = ( - bridge.execute as jest.Mock - ).getMockImplementation() as (call: LighterWasmCall) => Promise; - let releaseAuth: () => void = () => undefined; - let signalAuthMint: () => void = () => undefined; - const authMintStarted = new Promise((resolve) => { - signalAuthMint = resolve; - }); - let authPaused = false; - (bridge.execute as jest.Mock).mockImplementation( - async (call: LighterWasmCall) => { - if (call.function === '_createAuthToken' && !authPaused) { - authPaused = true; - signalAuthMint(); - await new Promise((resolve) => { - releaseAuth = resolve; - }); - } - return await realExecute(call); + it('stops at an exact failed transaction instead of polling through a later success', async () => { + const built = setup(); + built.clientInstance.getTx + .mockResolvedValueOnce({ ...executed, status: 1 }) + .mockResolvedValueOnce({ ...executed, status: 0 }) + .mockResolvedValue(executed); + + const pending = built.provider.editOrder(edit); + await jest.advanceTimersByTimeAsync(3000); + + expect(await pending).toMatchObject({ + success: false, + orderEdit: { status: 'failed' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }); + + it.each([ + { hash: 'foreign' }, + { accountIndex: 29 }, + { apiKeyIndex: 8 }, + { nonce: 43 }, + ])( + 'refuses foreign transaction identity after waiting: %j', + async (changed) => { + const built = setup(); + built.clientInstance.getTx + .mockResolvedValueOnce({ ...executed, status: 1 }) + .mockResolvedValue({ ...executed, ...changed }); + + const pending = built.provider.editOrder(edit); + await jest.advanceTimersByTimeAsync(3000); + + expect(await pending).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); }, ); - provider.subscribeToOrders({ callback: jest.fn() }); - await authMintStarted; - expect( - calls.filter((call) => call.function === '_createClient'), - ).toHaveLength(1); - await provider.disconnect(); - releaseAuth(); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); + it('refuses a foreign order observation after exact transaction execution', async () => { + const built = setup(); + built.clientInstance.getTx + .mockResolvedValueOnce({ ...executed, status: 1 }) + .mockImplementation(async () => { + built.setRow({ + ...target(), + ownerAccountIndex: 99, + price: '91000', + }); + return executed; + }); - expect(resetListenerCount()).toBe(0); - expect( - calls.filter((call) => call.function === '_createClient'), - ).toHaveLength(1); - }); + const pending = built.provider.editOrder(edit); + await jest.advanceTimersByTimeAsync(3000); - it('ignores a WebSocket open that arrives after disconnect', async () => { - StreamFakeWebSocket.instances = []; - const { provider, calls, resetListenerCount } = buildProvider({ - webSocketCtor: fakeStreamCtor, + expect(await pending).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); }); - const callback = jest.fn(); - provider.subscribeToPrices({ symbols: [], callback }); - const socket = StreamFakeWebSocket.instances[0]; - await provider.disconnect(); - socket.open(); + it.each(['account', 'network-disconnect'] as const)( + 'retires %s changes during the wait before another read or journal mutation', + async (change) => { + const built = setup(); + built.clientInstance.getTx.mockResolvedValue({ + ...executed, + status: 1, + }); + let completed = false; + const pending = built.provider.editOrder(edit).then((result) => { + completed = true; + return result; + }); + await jest.advanceTimersByTimeAsync(0); + expect(completed).toBe(false); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(1); + const retained = await built.deps.diskCache.getItem(journalKey); - expect(resetListenerCount()).toBe(0); - expect(socket.readyState).toBe(3); - expect(provider.getWebSocketConnectionState()).toBe('disconnected'); - expect(socket.sent).toStrictEqual([]); - expect(callback).not.toHaveBeenCalled(); - expect( - calls.filter((call) => call.function === '_createClient'), - ).toHaveLength(0); + if (change === 'account') { + built.getUserAddressMock.mockReturnValue(`0x${'11'.repeat(20)}`); + } else { + await built.provider.disconnect(); + } + await jest.advanceTimersByTimeAsync(3000); + + expect(await pending).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(1); + expect(await built.deps.diskCache.getItem(journalKey)).toBe(retained); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }, + ); }); - it('does not repopulate the market cache when initialization resolves after disconnect', async () => { - const { provider, clientInstance } = buildProvider(); - let resolveMarkets: (value: unknown) => void = () => undefined; - let signalMarketRead: () => void = () => undefined; - const marketReadStarted = new Promise((resolve) => { - signalMarketRead = resolve; + it('keeps an uncertain native edit fenced from margin writes after key migration', async () => { + const deps = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - clientInstance.getOrderBooks.mockImplementationOnce(() => { - signalMarketRead(); - return new Promise((resolve) => { - resolveMarkets = resolve; - }); + first.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - const pendingInitialize = provider.initialize(); - await marketReadStarted; - await provider.disconnect(); - - resolveMarkets([BTC_MARKET]); - expect(await pendingInitialize).toStrictEqual({ + first.clientInstance.getTx.mockResolvedValue({ + ...executed, + apiKeyIndex: 7, + status: 1, + }); + first.clientInstance.sendTx.mockRejectedValue( + new Error('edit response lost'), + ); + expect(await first.provider.editOrder(edit)).toMatchObject({ success: false, - error: 'Lighter provider is disconnected', + orderEdit: { status: 'pending' }, }); - expect( - await provider.fetchHistoricalCandles({ - symbol: 'BTC', - interval: '1h', - }), - ).toStrictEqual({ symbol: 'BTC', interval: '1h', candles: [] }); - expect(clientInstance.getOrderBooks).toHaveBeenCalledTimes(2); - expect(clientInstance.getCandles).not.toHaveBeenCalled(); - }); + expect(first.clientInstance.sendTx).toHaveBeenCalledTimes(1); + const originalJournal = await deps.diskCache.getItem(journalKey); + await first.provider.disconnect(); - it('does not repopulate margin metadata when a fetch resolves after disconnect', async () => { - const { provider, clientInstance } = buildProvider(); - let resolveMargins: (value: unknown) => void = () => undefined; - let signalMarginRead: () => void = () => undefined; - const marginReadStarted = new Promise((resolve) => { - signalMarginRead = resolve; + const migrated = buildProvider({ + apiKeyIndex: 19, + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - clientInstance.getOrderBookDetails - .mockImplementationOnce(() => { - signalMarginRead(); - return new Promise((resolve) => { - resolveMargins = resolve; - }); - }) - .mockResolvedValue({ - code: 200, - orderBookDetails: [ - { - symbol: 'BTC', - lastTradePrice: 100000, - minInitialMarginFraction: 400, - maintenanceMarginFraction: 240, - }, - ], - }); - const pendingLeverage = provider.getMaxLeverage('BTC'); - await marginReadStarted; - await provider.disconnect(); - - resolveMargins({ + Object.assign(migrated.bridge, { + getRecoverableKeyIndices: async (): Promise => [19, 7], + }); + migrated.clientInstance.getApiKeys.mockResolvedValue({ code: 200, - orderBookDetails: [ + apiKeys: [ + { apiKeyIndex: 7, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 19, publicKey: '9c'.repeat(40) }, + ], + }); + migrated.clientInstance.getTx.mockResolvedValue({ + ...executed, + apiKeyIndex: 7, + status: 1, + }); + migrated.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ { - symbol: 'BTC', - lastTradePrice: 100000, - minInitialMarginFraction: 400, - maintenanceMarginFraction: 240, + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], }, ], }); - await expect(pendingLeverage).rejects.toThrow( - 'margin metadata unavailable', - ); - await expect(provider.getMaxLeverage('BTC')).rejects.toThrow( - 'margin metadata unavailable', - ); - expect(clientInstance.getOrderBookDetails).toHaveBeenCalledTimes(2); + + const result = await migrated.provider.updateMargin({ + symbol: 'BTC', + amount: '10', + }); + + expect(result.success).toBe(false); + expect(migrated.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + migrated.calls.some((call) => call.function === '_signUpdateMargin'), + ).toBe(false); + expect(await deps.diskCache.getItem(journalKey)).toBe(originalJournal); + expect(originalJournal).not.toBeNull(); + await migrated.provider.disconnect(); }); - it('does not revive candle state or callbacks when history resolves after disconnect', async () => { - StreamFakeWebSocket.instances = []; - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeStreamCtor, + it('retains exact execution proof across later lookup loss and settles fresh same-order fields', async () => { + const deps = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - let resolveCandles: (value: unknown) => void = () => undefined; - let signalCandleRead: () => void = () => undefined; - const candleReadStarted = new Promise((resolve) => { - signalCandleRead = resolve; + first.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - clientInstance.getCandles - .mockImplementationOnce(() => { - signalCandleRead(); - return new Promise((resolve) => { - resolveCandles = resolve; - }); - }) - .mockResolvedValue({ code: 200, c: [] }); - const callback = jest.fn(); - provider.subscribeToCandles({ - symbol: 'BTC', - interval: '1h', - callback, + first.clientInstance.getTx.mockResolvedValue(executed); + expect(await first.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, }); - await candleReadStarted; - await provider.disconnect(); - - resolveCandles({ + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + restarted.clientInstance.getEditableOrders.mockResolvedValue({ code: 200, - c: [{ t: 1000, o: 1, h: 2, l: 0.5, c: 1.5, v: 10 }], + orders: [{ ...target(), price: '91000' }], }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); + restarted.clientInstance.getTx.mockRejectedValue( + new Error('lookup temporarily offline'), + ); + expect(await restarted.provider.editOrder(edit)).toMatchObject({ + success: true, + orderEdit: { status: 'settled' }, + }); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + }); - expect(callback).not.toHaveBeenCalled(); - expect(StreamFakeWebSocket.instances).toHaveLength(0); - const lateCallback = jest.fn(); - provider.subscribeToCandles({ - symbol: 'BTC', - interval: '1h', - callback: lateCallback, + it('releases an expired exact absent timeout only after rereading its target', async () => { + const deps = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - expect(lateCallback).not.toHaveBeenCalled(); - expect(clientInstance.getCandles).toHaveBeenCalledTimes(1); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + built.clientInstance.sendTx.mockRejectedValue(new Error('timeout')); + await built.provider.editOrder(edit); + const stored = JSON.parse( + (await deps.diskCache.getItem(journalKey)) ?? '', + ) as { expiresAt: number }; + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(stored.expiresAt + 31_000); + try { + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'failed' }, + }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + await built.provider.editOrder(edit); + expect( + built.calls + .filter((call) => call.function === '_signModifyOrder') + .map((call) => call.params[6]), + ).toStrictEqual([42, 42]); + } finally { + clock.mockRestore(); + } }); - it('does not resume delayed order-book subscription setup after disconnect', async () => { - StreamFakeWebSocket.instances = []; - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeStreamCtor, + it('never treats acceptance as absent after its signed expiry', async () => { + const deps = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - let rejectMarkets: (error: Error) => void = () => undefined; - let signalMarketRead: () => void = () => undefined; - const marketReadStarted = new Promise((resolve) => { - signalMarketRead = resolve; + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - clientInstance.getOrderBooks.mockImplementationOnce(() => { - signalMarketRead(); - return new Promise((_resolve, reject) => { - rejectMarkets = reject; + await built.provider.editOrder(edit); + const stored = JSON.parse( + (await deps.diskCache.getItem(journalKey)) ?? '', + ) as { expiresAt: number }; + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(stored.expiresAt + 31_000); + try { + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, }); - }); - const callback = jest.fn(); - const onError = jest.fn(); - provider.subscribeToOrderBook({ symbol: 'BTC', callback, onError }); - await marketReadStarted; - await provider.disconnect(); - - rejectMarkets(new Error('late market lookup failure')); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - - expect(callback).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - expect(StreamFakeWebSocket.instances).toHaveLength(0); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + } finally { + clock.mockRestore(); + } }); - it('does not repopulate price replay state when a poll resolves after disconnect', async () => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: null, + it.each(['prepared', 'signed', 'attempted'] as const)( + 'refuses dispatch when durable %s persistence fails', + async (phase) => { + const deps = createMockInfrastructure(); + const persist = jest + .mocked(deps.diskCache) + .setItem.getMockImplementation(); + jest + .mocked(deps.diskCache) + .setItem.mockImplementation(async (key: string, raw: string) => { + if ( + key === journalKey && + (JSON.parse(raw) as { phase: string }).phase === phase + ) { + throw new Error('private disk path'); + } + await persist?.(key, raw); + }); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('preserves durable attempted state when saving acceptance fails', async () => { + const deps = createMockInfrastructure(); + const persist = jest + .mocked(deps.diskCache) + .setItem.getMockImplementation(); + jest + .mocked(deps.diskCache) + .setItem.mockImplementation(async (key: string, raw: string) => { + if ( + key === journalKey && + (JSON.parse(raw) as { phase: string }).phase === 'accepted' + ) { + throw new Error('private disk error'); + } + await persist?.(key, raw); + }); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - let resolvePrices: (value: unknown) => void = () => undefined; - let signalPriceRead: () => void = () => undefined; - const priceReadStarted = new Promise((resolve) => { - signalPriceRead = resolve; + first.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - clientInstance.getOrderBookDetails.mockImplementationOnce(() => { - signalPriceRead(); - return new Promise((resolve) => { - resolvePrices = resolve; + expect(await first.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + const stored = JSON.parse( + (await deps.diskCache.getItem(journalKey)) ?? '', + ) as { expiresAt: number; phase: string }; + expect(stored.phase).toBe('attempted'); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(stored.expiresAt + 31_000); + try { + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + restarted.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); + expect(await restarted.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } + }); + + it('rejects corrupt durable storage before read authority or signer initialization', async () => { + const deps = createMockInfrastructure(); + await deps.diskCache.setItem(journalKey, '{"version":1}'); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - const callback = jest.fn(); - provider.subscribeToPrices({ symbols: [], callback }); - await priceReadStarted; - await provider.disconnect(); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(jest.mocked(built.bridge).createClient.mock.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await deps.diskCache.getItem(journalKey)).toBe('{"version":1}'); + }); - resolvePrices({ + it('refuses retry when the original signing slot is unavailable', async () => { + const deps = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + first.clientInstance.getEditableOrders.mockResolvedValue({ code: 200, - orderBookDetails: [{ ...BTC_MARKET, lastTradePrice: 12345 }], + orders: [target()], }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - - expect(callback).not.toHaveBeenCalled(); - const lateCallback = jest.fn(); - provider.subscribeToPrices({ symbols: [], callback: lateCallback }); - expect(lateCallback).not.toHaveBeenCalled(); - expect(provider.getWebSocketConnectionState()).toBe('disconnected'); - expect(clientInstance.getOrderBookDetails).toHaveBeenCalledTimes(1); + await first.provider.editOrder(edit); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + apiKeyIndex: 8, + }); + expect(await restarted.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.some( + (call) => + call.function === '_signModifyOrder' || + call.function === '_signChangePubKey', + ), + ).toBe(false); }); - it('refuses toggleTestnet', async () => { - const { provider } = buildProvider(); - const result = await provider.toggleTestnet(); - expect(result.success).toBe(false); - expect(result.isTestnet).toBe(true); - }); + it.each(['signing', 'submission'] as const)( + 'fences a session change during %s and retains original intent', + async (during) => { + const deps = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + const execute = jest + .mocked(built.bridge) + .execute.getMockImplementation(); + if (during === 'signing') { + jest.mocked(built.bridge).execute.mockImplementation(async (call) => { + const result = await execute?.(call); + if (call.function === '_signModifyOrder') { + built.getUserAddressMock.mockReturnValue(`0x${'11'.repeat(20)}`); + } + return result as never; + }); + } else { + built.clientInstance.sendTx.mockImplementation(async () => { + built.getUserAddressMock.mockReturnValue(`0x${'11'.repeat(20)}`); + return { code: 200 }; + }); + } + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + during === 'signing' ? 0 : 1, + ); + expect(await deps.diskCache.getItem(journalKey)).not.toBeNull(); + }, + ); - it('pings via the markets endpoint', async () => { - const { provider, clientInstance } = buildProvider(); - await provider.ping(); - expect(clientInstance.getOrderBooks).toHaveBeenCalled(); + it('refuses a target that fills while the signer is running and retires only its unsent nonce', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + let row = target(); + built.clientInstance.getEditableOrders.mockImplementation(async () => ({ + code: 200, + orders: [row], + })); + const execute = jest.mocked(built.bridge).execute.getMockImplementation(); + jest.mocked(built.bridge).execute.mockImplementation(async (call) => { + const result = await execute?.(call); + if (call.function === '_signModifyOrder') { + row = { + ...row, + filledBaseAmount: '0.0005', + remainingBaseAmount: '0.0005', + }; + } + return result as never; + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'failed' }, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + row = target(); + jest.mocked(built.bridge).execute.mockImplementation( + execute ?? + (async (): Promise => { + throw new Error('missing bridge'); + }), + ); + await built.provider.editOrder(edit); + expect( + built.calls + .filter((call) => call.function === '_signModifyOrder') + .map((call) => call.params[6]), + ).toStrictEqual([42, 42]); }); - }); - 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.each(['missing', 'foreign', 'pending'] as const)( + 'suppresses retry for %s settlement evidence', + async (caseName) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + await built.provider.editOrder(edit); + if (caseName === 'missing') { + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getTx.mockResolvedValue(executed); + } + if (caseName === 'foreign') { + built.clientInstance.getTx.mockResolvedValue({ + ...executed, + accountIndex: 99, + }); + } + if (caseName === 'pending') { + built.clientInstance.getTx.mockResolvedValue({ + ...executed, + status: 3, + }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [{ ...target(), price: '91000' }], + }); + } + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }, + ); - it('queries all API-key slots and registers the venue key when the configured slot is missing', async () => { - const { provider, clientInstance, calls, bridge } = buildProvider(); - const result = await provider.isReadyToTrade(); - expect(result.ready).toBe(true); - // A direct lookup of an unused slot returns venue error 21109 - // (`api key not found`); querying all slots returns an empty list. - expect(clientInstance.getApiKeys).toHaveBeenCalledWith(28); - expect(bridge.createClient).toHaveBeenCalledWith({ - chainId: 300, - accountIndex: 28, - nonce: 42, - apiKeyIndex: 7, + it('reports exact transaction failure and permits only a subsequent explicit refreshed retry', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - const callNames = calls.map((call) => call.function); - expect(callNames).toContain('_createClient'); - expect(callNames).toContain('_signChangePubKey'); - expect(clientInstance.sendTx).toHaveBeenCalledWith( - 8, - expect.stringContaining('"changePubKey":true'), - ); - }); + built.clientInstance.getTx.mockResolvedValue({ ...executed, status: 0 }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'failed' }, + }); + await built.provider.editOrder(edit); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect( + built.calls + .filter((call) => call.function === '_signModifyOrder') + .map((call) => call.params[6]), + ).toStrictEqual([42, 43]); + }); + + it.each(['managed', 'chase'] as const)( + 'refuses an explicitly recorded %s child that looks like an ordinary limit', + async (strategy) => { + const deps = createMockInfrastructure(); + const wallet = ACCOUNT.l1Address.toLowerCase(); + if (strategy === 'managed') { + await deps.diskCache.setItem( + `lighterManagedTpsl:testnet:${wallet}:28:BTC`, + JSON.stringify({ + version: 1, + orders: [{ clientId: '1', orderId: edit.orderId }], + }), + ); + } else { + await deps.diskCache.setItem( + `lighterChase:${JSON.stringify(['testnet', wallet, 28])}`, + JSON.stringify({ + version: 1, + records: [ + { + intent: { + owner: { + wallet, + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 7, + }, + handle: 'test-chase', + symbol: 'BTC', + marketId: 1, + isBuy: true, + reduceOnly: false, + originalSize: '0.0002', + arrivalPrice: '90000', + sizeDecimals: 5, + priceDecimals: 1, + startedAt: Date.now(), + intervalMs: 1000, + maxDurationMs: 10000, + maxRepricings: 1, + maxDistanceBps: 100, + maxNotional: '20', + minBaseAmount: '0.0002', + minQuoteAmount: '10', + }, + children: [ + { + clientOrderId: '1', + size: '0.0002', + price: '90000', + quotedAt: Date.now(), + placement: { + phase: 'attempted', + nonce: 1, + txHash: 'dddd000000000002', + expiresAt: Date.now() + 599000, + }, + cancellations: [], + }, + ], + status: 'active', + repricings: 0, + lastTickAt: Date.now(), + executedSize: '0', + executedNotional: '0', + }, + ], + }), + ); + } + const chase = new LighterChaseService({ storage: deps.diskCache }); + expect( + await chase.hasRecordedChild( + { wallet, network: 'testnet', accountIndex: 28, apiKeyIndex: 7 }, + '1', + { assertCurrent: () => undefined }, + ), + ).toBe(strategy === 'chase'); + const before = jest.mocked(deps.diskCache).setItem.mock.calls.length; + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: deps, + }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(jest.mocked(deps.diskCache).setItem.mock.calls).toHaveLength( + before, + ); + }, + ); - 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, + it('keeps accepted but unchanged intent pending and suppresses duplicate dispatch', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - 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'), - ); + built.clientInstance.getTx.mockResolvedValue(executed); + const first = await built.provider.editOrder(edit); + expect(first).toMatchObject({ + success: false, + orderEdit: { + status: 'pending', + requestedPrice: '91000', + requestedSize: '0.001', + }, + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect( + built.calls.filter((call) => call.function === '_signModifyOrder'), + ).toHaveLength(1); }); - it('mainnet AUTHENTICATED reads work when the venue key is already registered (no dispatch needed)', async () => { - const { provider, calls, clientInstance } = buildProvider({ - isTestnet: false, + it('retains a timed-out dispatch across restart and settles the same order without retrying', async () => { + const deps = createMockInfrastructure(); + const first = buildProvider({ registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - 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({ + first.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + first.clientInstance.sendTx.mockRejectedValue( + new Error('private transport detail'), + ); + expect(await first.provider.editOrder(edit)).toMatchObject({ + success: false, + orderEdit: { status: 'pending' }, + }); + const restarted = buildProvider({ registeredKey: '9c'.repeat(40), + platformDependencies: deps, }); - const result = await provider.isReadyToTrade(); - expect(result.ready).toBe(true); - expect(calls.map((call) => call.function)).not.toContain( - '_signChangePubKey', + restarted.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [{ ...target(), price: '91000' }], + }); + restarted.clientInstance.getTx.mockResolvedValue(executed); + expect(await restarted.provider.editOrder(edit)).toMatchObject({ + success: true, + orderEdit: { status: 'settled' }, + }); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.some((call) => call.function === '_signModifyOrder'), + ).toBe(false); + expect(restarted.clientInstance.getEditableOrders).toHaveBeenCalledWith( + 28, + expect.any(String), + ['1'], ); - expect(clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('fails closed when the venue-key lookup fails', async () => { - const { provider, clientInstance, calls } = buildProvider(); - clientInstance.getApiKeys.mockRejectedValue(new Error('temporary 503')); - - const result = await provider.isReadyToTrade(); - - expect(result.ready).toBe(false); - expect(result.error).toContain('temporary 503'); - expect(calls.map((call) => call.function)).not.toContain( - '_signChangePubKey', - ); - expect(clientInstance.sendTx).not.toHaveBeenCalled(); - }); + it.each([ + { ownerAccountIndex: 99 }, + { marketIndex: 2 }, + { orderIndex: '288230376151711746' }, + { type: 'stop-loss-limit', triggerPrice: '80000' }, + { parentOrderIndex: '9' }, + { remainingBaseAmount: '0.0005', filledBaseAmount: '0.0005' }, + { status: 'canceled' }, + { timeInForce: 'immediate-or-cancel' }, + ])( + 'refuses unsupported or foreign target %j before financial signing', + async (changes) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [{ ...target(), ...changes }], + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + built.calls.some((call) => call.function === '_signModifyOrder'), + ).toBe(false); + }, + ); - it('refuses to replace a different key already registered in the configured slot', async () => { - const { provider, clientInstance, calls } = buildProvider({ - registeredKey: 'ab'.repeat(40), + it.each([ + { price: '91000.01' }, + { price: '429496729.6' }, + { size: '0.001001' }, + { size: '2814749767.10656' }, + { isBuy: false }, + { triggerPrice: '80000' }, + ])('refuses unsafe mutation %j', async (changes) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], }); - - const result = await provider.isReadyToTrade(); - - expect(result.ready).toBe(false); - expect(result.error).toContain('automatic replacement is disabled'); - expect(calls.map((call) => call.function)).not.toContain( - '_signChangePubKey', - ); - expect(clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + await built.provider.editOrder({ + ...edit, + newOrder: { ...edit.newOrder, ...changes }, + }), + ).toMatchObject({ success: false }); + expect(built.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.each(['fill', 'cancel'] as const)( + 'reports the exact %s race without fabricated success', + async (race) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + let row = target(); + built.clientInstance.getEditableOrders.mockImplementation(async () => ({ + code: 200, + orders: [row], + })); + built.clientInstance.getTx.mockResolvedValue(executed); + built.clientInstance.sendTx.mockImplementation(async () => { + row = + race === 'fill' + ? { + ...row, + price: '91000', + remainingBaseAmount: '0.0005', + filledBaseAmount: '0.0005', + } + : { ...row, status: 'canceled', remainingBaseAmount: '0' }; + return { code: 200 }; + }); + const result = await built.provider.editOrder(edit); + expect(result).toMatchObject({ + success: false, + orderEdit: { + status: race === 'fill' ? 'pending' : 'terminal', + observation: { status: race === 'fill' ? 'open' : 'canceled' }, + }, + }); + expect(result).not.toHaveProperty('filledSize'); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }, + ); - it('returns empty markets on API failure', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getOrderBooks.mockRejectedValue(new Error('down')); - expect(await provider.getMarkets()).toStrictEqual([]); + it('refuses ambiguous duplicate exact identities', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target(), target()], + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('surfaces malformed venue market data instead of reporting no markets', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getOrderBooks.mockRejectedValue( - new Error('Invalid Lighter venue data: malformed market'), - ); - await expect(provider.getMarkets()).rejects.toThrow( - 'Invalid Lighter venue data', - ); + it('never signs a rounded caller order identity', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + expect( + await built.provider.editOrder({ + ...edit, + orderId: Number(edit.orderId), + }), + ).toMatchObject({ success: false }); + expect(built.clientInstance.getEditableOrders).not.toHaveBeenCalled(); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - 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'); + it('refuses an actual bridge response signed for a different exact order', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getEditableOrders.mockResolvedValue({ + code: 200, + orders: [target()], + }); + const execute = jest.mocked(built.bridge).execute.getMockImplementation(); + jest.mocked(built.bridge).execute.mockImplementation(async (call) => { + const result = await execute?.(call); + if ( + call.function === '_signModifyOrder' && + result && + 'txInfo' in result && + typeof result.txInfo === 'string' + ) { + return { + ...result, + txInfo: result.txInfo.replace(edit.orderId, '288230376151711746'), + }; + } + return result as never; + }); + expect(await built.provider.editOrder(edit)).toMatchObject({ + success: false, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('omits inactive markets without usable risk metadata while preserving active markets', async () => { - const built = buildProvider(); - const inactiveMarket = { - ...BTC_MARKET, - symbol: 'MKR', - marketId: 28, - status: 'inactive', - minBaseAmount: '0.0000', - minQuoteAmount: '0.000000', - }; - const activeDetail = { - ...BTC_MARKET, - lastTradePrice: 100000, - dailyTradesCount: 10, - dailyBaseTokenVolume: 1, - dailyQuoteTokenVolume: 100000, - dailyPriceLow: 99000, - dailyPriceHigh: 101000, - dailyPriceChange: 1, - openInterest: 1000000, - dailyChart: {}, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }; - built.clientInstance.getOrderBooks.mockResolvedValue([ - BTC_MARKET, - inactiveMarket, - ]); - built.clientInstance.getOrderBookDetails.mockResolvedValue({ + it('keeps a previous settled intent separate from a different retry request', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + let row = target(); + built.clientInstance.getEditableOrders.mockImplementation(async () => ({ code: 200, - orderBookDetails: [ - activeDetail, - { - ...activeDetail, - ...inactiveMarket, - defaultInitialMarginFraction: 0, - minInitialMarginFraction: 0, - maintenanceMarginFraction: 0, - }, - ], + orders: [row], + })); + built.clientInstance.getTx.mockResolvedValue(executed); + await built.provider.editOrder(edit); + row = { ...row, price: '91000' }; + const next = await built.provider.editOrder({ + ...edit, + newOrder: { ...edit.newOrder, price: '92000' }, + }); + expect(next).toMatchObject({ + success: false, + orderEdit: { status: 'settled', requestedPrice: '91000' }, }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }); - const markets = await built.provider.getMarkets(); - const marketData = await built.provider.getMarketDataWithPrices(); + it.each(['price', 'size'] as const)( + 'settles a native %s edit on the same exact int64 order', + async (field) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + let row = { + orderIndex: '288230376151711745', + orderId: '288230376151711745', + clientOrderIndex: 1, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + filledBaseAmount: '0', + price: '90000', + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: 1700000000, + }; + Object.assign(built.clientInstance, { + getEditableOrders: jest + .fn() + .mockImplementation(async () => ({ code: 200, orders: [row] })), + }); + built.clientInstance.sendTx.mockImplementation( + async (kind: number, txInfo: string) => { + if (kind !== 17) { + throw new Error('Unexpected edit transaction type'); + } + const tx = JSON.parse(txInfo) as { + Price: number; + BaseAmount: number; + Nonce: number; + }; + row = { + ...row, + price: String(tx.Price / 10), + initialBaseAmount: String(tx.BaseAmount / 100000), + remainingBaseAmount: String(tx.BaseAmount / 100000), + }; + built.clientInstance.getTx.mockImplementation( + async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: tx.Nonce, + status: 2, + }), + ); + return { code: 200, txHash: 'accepted' }; + }, + ); + const result = await built.provider.editOrder({ + orderId: row.orderId, + newOrder: { + symbol: 'BTC', + orderType: 'limit', + isBuy: true, + size: field === 'size' ? '0.002' : '0.001', + price: field === 'price' ? '91000' : undefined, + }, + }); + expect(result).toMatchObject({ + success: true, + orderId: '288230376151711745', + }); + expect( + built.calls.filter((call) => call.function === '_signModifyOrder'), + ).toStrictEqual([ + { + function: '_signModifyOrder', + params: [ + 28, + 1, + '288230376151711745', + field === 'size' ? 200 : 100, + field === 'price' ? 910000 : 900000, + 0, + 42, + ], + }, + ]); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }, + ); + }); - expect(markets.map((market) => market.name)).toStrictEqual(['BTC']); - expect(marketData.map((market) => market.symbol)).toStrictEqual(['BTC']); + describe('provider-owned Scale sizing preview', () => { + it('refuses a native-overflow Scale child before preview readiness, setup or leverage transport', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, supportedSizeDecimals: 12 }, + ]); + await expect( + built.provider.getScalePriceLadder({ + symbol: 'BTC', + minPrice: 99000, + maxPrice: 100000, + count: 2, + sizing: { size: '600' }, + }), + ).rejects.toThrow('Lighter Scale child exceeds native base amount'); + const request: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'scale', + size: '600', + scaleMinPrice: '99000', + scaleMaxPrice: '100000', + scaleNumOrders: 2, + scaleSkew: 1, + leverage: 2, + }; + expect((await built.provider.validateOrder(request)).isValid).toBe(false); + expect((await built.provider.placeOrder(request)).success).toBe(false); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('preserves inactive markets when their risk metadata remains usable', async () => { + const previewIntent = { + symbol: 'BTC', + minPrice: 99000, + maxPrice: 100000, + count: 3, + }; + + it('previews exact sizes below the quote budget without account or signer setup', async () => { const built = buildProvider(); - const inactiveMarket = { - ...BTC_MARKET, - symbol: 'MKR', - marketId: 28, - status: 'inactive', - }; - built.clientInstance.getOrderBooks.mockResolvedValue([inactiveMarket]); - built.clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [ - { - ...inactiveMarket, - lastTradePrice: 2500, - dailyTradesCount: 0, - dailyBaseTokenVolume: 0, - dailyQuoteTokenVolume: 0, - dailyPriceLow: 2500, - dailyPriceHigh: 2500, - dailyPriceChange: 0, - openInterest: 0, - dailyChart: {}, - minInitialMarginFraction: 500, - maintenanceMarginFraction: 250, - }, - ], + const result = await built.provider.getScalePriceLadder({ + ...previewIntent, + sizing: { usdAmount: '60' }, }); - const markets = await built.provider.getMarkets(); - const marketData = await built.provider.getMarketDataWithPrices(); - - expect(markets).toHaveLength(1); - expect(markets[0]).toMatchObject({ - name: 'MKR', - isDelisted: true, - maxLeverage: 20, + expect(result).toStrictEqual({ + status: 'ready', + providerId: 'lighter', + prices: ['99000', '99500', '100000'], + sizingPreview: { + sizes: ['0.0002', '0.0002', '0.0002'], + totalSize: '0.0006', + totalNotional: '59.7', + minimumBaseSize: '0.00020', + minimumQuoteAmount: '10.000000', + sizeDecimals: 5, + }, }); - expect(marketData.map((market) => market.symbol)).toStrictEqual(['MKR']); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('continues to reject zero risk metadata for active markets', async () => { + it('rounds an explicit base intent down without increasing exposure', async () => { const built = buildProvider(); - built.clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - dailyTradesCount: 10, - dailyBaseTokenVolume: 1, - dailyQuoteTokenVolume: 100000, - dailyPriceLow: 99000, - dailyPriceHigh: 101000, - dailyPriceChange: 1, - openInterest: 1000000, - dailyChart: {}, - defaultInitialMarginFraction: 0, - minInitialMarginFraction: 0, - maintenanceMarginFraction: 0, - }, - ], + const result = await built.provider.getScalePriceLadder({ + ...previewIntent, + sizing: { size: '0.000609' }, }); - await expect(built.provider.getMarkets()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - await expect(built.provider.getMarketDataWithPrices()).rejects.toThrow( - 'Invalid Lighter venue data', - ); + expect(result).toMatchObject({ + sizingPreview: { + totalSize: '0.0006', + sizes: ['0.0002', '0.0002', '0.0002'], + totalNotional: '59.7', + }, + }); + expect(built.calls).toStrictEqual([]); }); - it('does not publish market metadata without verified per-market leverage', async () => { - const markets = buildProvider(); - markets.clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], + it('uses exact skewed sizes from the placement builder', async () => { + const built = buildProvider(); + const result = await built.provider.getScalePriceLadder({ + symbol: 'BTC', + minPrice: 99000, + maxPrice: 100000, + count: 2, + sizing: { size: '0.0012', skew: 2 }, }); - await expect(markets.provider.getMarkets()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - const marketData = buildProvider(); - marketData.clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - dailyPriceChange: 1, - }, - ], + expect(result).toMatchObject({ + sizingPreview: { + sizes: ['0.0004', '0.0008'], + totalSize: '0.0012', + totalNotional: '119.6', + }, }); + }); + + it.each([ + ['1', PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL], + ['0', PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE], + ['NaN', PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE], + ['60usd', PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE], + ])( + 'rejects a quote intent %s that cannot produce valid maker rungs', + async (usdAmount, message) => { + const built = buildProvider(); + await expect( + built.provider.getScalePriceLadder({ + ...previewIntent, + sizing: { usdAmount }, + }), + ).rejects.toThrow(message); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it.each(['0', 'NaN', '60usd'])( + 'reports a positive-size error for base intent %s', + async (size) => { + const built = buildProvider(); + + await expect( + built.provider.getScalePriceLadder({ + ...previewIntent, + sizing: { size }, + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_SIZE_POSITIVE); + + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { usdAmount: '60', skew: 0 }, + { size: '0.0006', skew: Number.NaN }, + ])( + 'preserves the range error for nonpositive or nonfinite skew %j', + async (sizing) => { + const built = buildProvider(); + + await expect( + built.provider.getScalePriceLadder({ ...previewIntent, sizing }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); + + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('reports the quote minimum when every rung satisfies the base minimum', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBooks.mockResolvedValueOnce([ + { ...BTC_MARKET, minQuoteAmount: '100' }, + ]); + await expect( - marketData.provider.getMarketDataWithPrices(), - ).rejects.toThrow('Invalid Lighter venue data'); + built.provider.getScalePriceLadder({ + ...previewIntent, + sizing: { size: '0.0006' }, + }), + ).rejects.toThrow(PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL); + + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('rejects margin fractions that would derive leverage below 1x', async () => { + it('captures the sizing intent before deferred market metadata arrives', async () => { const built = buildProvider(); - built.clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - minInitialMarginFraction: 10001, - maintenanceMarginFraction: 10001, - }, - ], + const metadata = createDeferred<(typeof BTC_MARKET)[]>(); + built.clientInstance.getOrderBooks.mockReturnValueOnce(metadata.promise); + const params = { ...previewIntent, sizing: { usdAmount: '60' } }; + const result = built.provider.getScalePriceLadder(params); + params.sizing.usdAmount = '600'; + params.symbol = 'SOL'; + params.minPrice = 1; + params.maxPrice = 2; + params.count = 2; + metadata.resolve([BTC_MARKET]); + + expect(await result).toMatchObject({ + sizingPreview: { totalSize: '0.0006', totalNotional: '59.7' }, }); - await expect(built.provider.getMarkets()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - await expect(built.provider.getPositions()).rejects.toThrow( - 'Invalid Lighter venue data', - ); }); - it.each([Number.MIN_VALUE, 1.5])( - 'rejects non-integer margin fraction %p through market and position reads', - async (marginFraction) => { + it.each([{}, { size: '0.0006', usdAmount: '60' }])( + 'rejects sizing without exactly one exposure intent: %j', + async (sizing) => { const built = buildProvider(); - built.clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - minInitialMarginFraction: marginFraction, - maintenanceMarginFraction: marginFraction, - }, - ], - }); - await expect(built.provider.getMarkets()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - await expect(built.provider.getPositions()).rejects.toThrow( - 'Invalid Lighter venue data', + const params = { ...previewIntent }; + Object.assign(params, { sizing }); + + await expect( + built.provider.getScalePriceLadder(params), + ).rejects.toThrow( + 'Lighter Scale sizing requires exactly one exposure intent', ); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }, ); - it('surfaces malformed successful order-book details instead of reporting no market data', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getOrderBookDetails.mockRejectedValue( - new Error('Invalid Lighter venue data: malformed order-book details'), - ); - await expect(provider.getMarketDataWithPrices()).rejects.toThrow( - 'Invalid Lighter venue data', - ); + it('refuses a conflicting explicit provider route before loading metadata', async () => { + const built = buildProvider(); + const result = await built.provider.getScalePriceLadder({ + ...previewIntent, + providerId: 'hyperliquid', + sizing: { usdAmount: '60' }, + }); + expect(result).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_not_routable', + }); + expect(built.clientInstance.getOrderBooks).not.toHaveBeenCalled(); + expect(built.calls).toStrictEqual([]); }); - }); - 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', + it('retains the existing price-only contract when sizing is omitted', async () => { + const built = buildProvider(); + expect( + await built.provider.getScalePriceLadder(previewIntent), + ).toStrictEqual({ + status: 'ready', providerId: 'lighter', + prices: ['99000', '99500', '100000'], }); }); + }); - it('preserves retired-market positions when risk metadata is zeroed', async () => { - const built = buildProvider(); - const inactiveMarket = { - ...BTC_MARKET, - symbol: 'MKR', - marketId: 28, - status: 'inactive', - minBaseAmount: '0.0000', - minQuoteAmount: '0.000000', + describe('durable Scale ladder', () => { + const intent: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'scale', + scaleMinPrice: '90000', + scaleMaxPrice: '100000', + scaleNumOrders: 2, + }; + + /** Install a venue that records writes and returns only real accepted rows. + * @param built - Provider and mock boundaries. + * @returns Mutable authoritative order rows for race tests. + */ + function scaleVenue(built: BuiltProvider): { + active: LighterApiOrder[]; + history: LighterApiOrder[]; + } { + const venue: { active: LighterApiOrder[]; history: LighterApiOrder[] } = { + active: [], + history: [], }; - built.clientInstance.getOrderBooks.mockResolvedValue([ - BTC_MARKET, - inactiveMarket, - ]); - built.clientInstance.getOrderBookDetails.mockResolvedValue({ + built.clientInstance.getActiveOrders.mockImplementation(async () => ({ code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - { - ...inactiveMarket, - lastTradePrice: 0, - minInitialMarginFraction: 0, - maintenanceMarginFraction: 0, - }, - ], - }); - built.clientInstance.getAccountByIndex.mockResolvedValue({ + orders: [...venue.active], + })); + built.clientInstance.getInactiveOrders.mockImplementation(async () => ({ code: 200, - accounts: [ - { - ...ACCOUNT, - positions: [ - ...(ACCOUNT.positions ?? []), - { - ...ACCOUNT.positions?.[0], - marketId: 28, - symbol: 'MKR', - initialMarginFraction: '10', - }, - ], - }, - ], - }); - await built.provider.initialize(); - - const positions = await built.provider.getPositions(); - - expect(positions).toStrictEqual([ - expect.objectContaining({ symbol: 'BTC', maxLeverage: 50 }), - expect.objectContaining({ - symbol: 'MKR', - leverage: expect.objectContaining({ value: 10 }), - maxLeverage: 10, - }), - ]); - }); - - it('surfaces margin metadata transport failures from position reads', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getOrderBookDetails.mockRejectedValue( - new Error('margin metadata unavailable'), - ); - await expect(provider.getPositions()).rejects.toThrow( - 'margin metadata unavailable', + orders: [...venue.history], + })); + built.clientInstance.sendTx.mockImplementation( + async (_kind: number, txInfo: string) => { + const wire = JSON.parse(txInfo) as { + createOrder?: boolean; + cancelOrder?: boolean; + ClientOrderIndex?: number; + }; + if (wire.createOrder) { + const call = [...built.calls] + .reverse() + .find( + (item) => + item.function === '_signCreateOrder' && + Number(item.params[2]) === wire.ClientOrderIndex, + ); + if (!call) { + throw new Error('Missing signed fixture intent'); + } + venue.active.push({ + orderIndex: 800 + venue.active.length + venue.history.length, + clientOrderIndex: Number(call.params[2]), + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: String(Number(call.params[3]) / 100000), + remainingBaseAmount: String(Number(call.params[3]) / 100000), + filledBaseAmount: '0', + price: String(Number(call.params[4]) / 10), + isAsk: Number(call.params[5]) === 1, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: Number(call.params[8]), + status: 'open', + orderExpiry: 0, + timestamp: Date.now(), + }); + } + if (wire.cancelOrder) { + const call = [...built.calls] + .reverse() + .find((item) => item.function === '_signCancelOrder'); + const index = venue.active.findIndex( + (row) => String(row.orderIndex) === String(call?.params[2]), + ); + if (index >= 0) { + const [row] = venue.active.splice(index, 1); + venue.history.push({ ...row, status: 'canceled' }); + } + } + return { code: 200, txHash: '0xsent' }; + }, ); - expect(clientInstance.getAccountByIndex).not.toHaveBeenCalled(); - }); - - it('returns adapted account state', async () => { - const { provider } = buildProvider(); - const state = await provider.getAccountState(); - expect(state.totalBalance).toBe('10500'); - expect(state.providerId).toBe('lighter'); - }); + return venue; + } - 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'); - }); + describe('bounded Scale settlement', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); - it('surfaces malformed REST account numbers instead of degrading to empty state', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ - code: 200, - accounts: [{ ...ACCOUNT, collateral: '10000USD' }], + it('waits beyond the TP/SL window for exact delayed children without replay', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getActiveOrders.mockImplementation(async () => ({ + code: 200, + orders: venue.active.filter( + (row) => Date.now() - Number(row.timestamp) >= 2000, + ), + })); + const placing = built.provider.placeOrder(intent); + await jest.advanceTimersByTimeAsync(5000); + const result = await placing; + expect(result).toMatchObject({ + success: true, + childOrderIds: ['800', '801'], + acceptedSize: '0.001', + }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + const creates = built.calls.filter( + (call) => call.function === '_signCreateOrder', + ); + expect(creates).toHaveLength(2); + expect(new Set(creates.map((call) => call.params.at(-1))).size).toBe(2); + expect( + (await built.provider.getScaleOrderGroups())[0].acceptedChildren, + ).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ orderId: '800', state: 'resting' }), + expect.objectContaining({ orderId: '801', state: 'resting' }), + ]), + ); }); - await expect(provider.getAccountState()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - }); - - 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', + it('waits for exact delayed cancellations without duplicate signs or sends', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const send = built.clientInstance.sendTx.getMockImplementation(); + let canceledAt = 0; + let stale: LighterApiOrder | undefined; + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + stale = venue.active[0]; + canceledAt = Date.now(); + await send?.(...args); + return { code: 200, txHash: '0xsent' }; + }, + ); + built.clientInstance.getActiveOrders.mockImplementation(async () => ({ + code: 200, + orders: + stale && Date.now() - canceledAt < 2000 + ? [stale, ...venue.active] + : [...venue.active], + })); + built.clientInstance.getInactiveOrders.mockImplementation(async () => ({ + code: 200, + orders: Date.now() - canceledAt < 2000 ? [] : [...venue.history], + })); + const canceling = built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }); + await jest.advanceTimersByTimeAsync(5000); + expect(await canceling).toMatchObject({ success: true }); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(4); + expect((await built.provider.getScaleOrderGroups())[0].state).toBe( + 'terminal', + ); }); - expect(clientInstance.getActiveOrders).toHaveBeenCalledWith( - 28, - 'auth-token', - ); - }); - it('surfaces malformed REST order state instead of returning an empty list', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getActiveOrders.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: 'venue-added-state', - orderExpiry: 0, - timestamp: 1700000000000, - }, - ], + it('bounds missing placement visibility and later reconciles without replay', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + let finished = false; + const placing = built.provider.placeOrder(intent).then((result) => { + finished = true; + return result; + }); + await jest.advanceTimersByTimeAsync(9999); + expect(finished).toBe(false); + await jest.advanceTimersByTimeAsync(1); + expect(await placing).toMatchObject({ success: false }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: venue.active, + }); + await built.provider.reviewScaleOrderGroups(); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); }); - await expect(provider.getOpenOrders()).rejects.toThrow( - 'Invalid Lighter venue data', + it.each(['placement', 'cancellation'] as const)( + 'stops delayed %s settlement when the issuing session changes', + async (phase) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + const placed = + phase === 'cancellation' + ? await built.provider.placeOrder(intent) + : undefined; + if (placed) { + built.clientInstance.sendTx.mockResolvedValue({ + code: 200, + txHash: '0xsent', + }); + } else { + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getTx.mockResolvedValue(null); + } + const pending = placed + ? built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }) + : built.provider.placeOrder(intent); + await jest.advanceTimersByTimeAsync(2000); + built.fireReset(); + const reads = built.clientInstance.getActiveOrders.mock.calls.length; + await jest.advanceTimersByTimeAsync(250); + expect(await pending).toMatchObject({ + success: false, + error: expect.stringMatching(/session|reset/u) as string, + }); + expect(built.clientInstance.getActiveOrders).toHaveBeenCalledTimes( + reads, + ); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(placed ? 2 : 0); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(placed ? 2 : 1); + }, ); - }); - it('builds a CAIP account id from the L1 address', async () => { - const { provider } = buildProvider(); - expect(await provider.getCurrentAccountId()).toBe( - 'eip155:300:0x8D7f03FdE1A626223364E592740a233b72395235', + it.each([false, true])( + 'does not replay unresolved Scale cancellations after restart=%s', + async (restart) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const stale = [...venue.active]; + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: stale, + }); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + const params = { + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale' as const, + }; + const first = built.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await first).toMatchObject({ + success: false, + error: 'Lighter Scale cancellation remains unresolved', + }); + const current = restart + ? buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }) + : built; + if (restart) { + scaleVenue(current); + current.clientInstance.getTx.mockResolvedValue(null); + current.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: stale, + }); + current.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + } + const repeated = current.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await repeated).toMatchObject({ + success: false, + error: 'Lighter Scale cancellation remains unresolved', + }); + const workers = restart ? [built, current] : [built]; + expect( + workers + .flatMap((worker) => worker.calls) + .filter((call) => call.function === '_signCancelOrder') + .map((call) => [call.params[2], call.params[3]]), + ).toStrictEqual([ + ['800', 44], + ['801', 45], + ]); + expect( + workers.reduce( + (count, worker) => + count + worker.clientInstance.sendTx.mock.calls.length, + 0, + ), + ).toBe(4); + current.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: venue.active, + }); + current.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: venue.history, + }); + expect( + (await current.provider.reviewScaleOrderGroups())[0].state, + ).toBe('terminal'); + expect(await current.provider.cancelOrder(params)).toMatchObject({ + success: true, + }); + expect( + workers.reduce( + (count, worker) => + count + worker.clientInstance.sendTx.mock.calls.length, + 0, + ), + ).toBe(4); + }, ); - }); - }); - - describe('getMarginModeLock', () => { - it('reports cross when an open position omits its margin mode', async () => { - const { provider } = buildProvider(); - - const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); - - expect(lock).toStrictEqual({ - status: 'locked', - providerId: 'lighter', - marginMode: 'cross', - reason: 'position', - }); - }); - it('reports isolated when the open position is isolated', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ - code: 200, - accounts: [ - { - ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], marginMode: 1 }], + it('retains an uncertain Scale cancellation across response loss and restart', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const stale = [...venue.active]; + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost cancellation response'); }, - ], - }); - - const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); - - expect(lock).toStrictEqual({ - status: 'locked', - providerId: 'lighter', - marginMode: 'isolated', - reason: 'position', - }); - }); - - it.each([ - ['no position row', 'ETH', ACCOUNT.positions], - [ - 'a zero-size position', - 'BTC', - [{ ...ACCOUNT.positions[0], position: '0' }], - ], - ])('reports unlocked for %s', async (_case, symbol, positions) => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ - code: 200, - accounts: [{ ...ACCOUNT, positions }], + ); + const params = { + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale' as const, + }; + expect(await built.provider.cancelOrder(params)).toMatchObject({ + success: false, + }); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(group.rungs[0].cancelAttempt).toMatchObject({ nonce: 44 }); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(restarted); + restarted.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: stale, + }); + restarted.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + restarted.clientInstance.getTx.mockResolvedValue(null); + const repeated = restarted.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await repeated).toMatchObject({ success: false }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(3); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0] + .rungs[0].cancelAttempt, + ).toStrictEqual(group.rungs[0].cancelAttempt); }); - const lock = await provider.getMarginModeLock({ symbol }); - - expect(lock).toStrictEqual({ status: 'unlocked', providerId: 'lighter' }); - }); - - it.each([ - ['no account', { code: 200, accounts: [] }], - [ - 'no positions array', - { code: 200, accounts: [{ ...ACCOUNT, positions: undefined }] }, - ], - ])( - 'reports unavailable when the account response has %s', - async (_case, response) => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue(response); - - const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); - - expect(lock).toStrictEqual({ - status: 'unavailable', - providerId: 'lighter', - reason: 'provider_unavailable', + it('does not dispatch when Scale cancellation ownership cannot be persisted', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - }, - ); + scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const writeSpy = jest.spyOn(infra.diskCache, 'setItem'); + const write = writeSpy.getMockImplementation(); + writeSpy.mockImplementation(async (cacheKey: string, value: string) => { + if ( + cacheKey === key && + parseLighterScaleGroups(value).some((group) => + group.rungs.some((rung) => rung.cancelAttempt !== undefined), + ) + ) { + throw new Error('cancellation storage unavailable'); + } + await write?.(cacheKey, value); + }); + expect( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }), + ).toMatchObject({ + success: false, + error: 'cancellation storage unavailable', + }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect( + parseLighterScaleGroups( + await infra.diskCache.getItem(key), + )[0].rungs.every((rung) => rung.cancelAttempt === undefined), + ).toBe(true); + }); + + it.each([false, true])( + 'releases a never-landed expired Scale cancellation after restart=%s', + async (restart) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const second = venue.active.pop(); + if (!second) { + throw new Error('Missing second Scale child'); + } + venue.history.push({ ...second, status: 'canceled' }); + const unrelated = { + ...second, + orderIndex: 9999, + clientOrderIndex: second.clientOrderIndex + 1, + }; + venue.active.push(unrelated); + built.clientInstance.sendTx.mockRejectedValue( + new Error('never reached venue'), + ); + const params = { + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale' as const, + }; + expect(await built.provider.cancelOrder(params)).toMatchObject({ + success: false, + }); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [pending] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const attempt = pending.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing cancellation identity'); + } + expect(attempt.acknowledged).toBe(false); + await jest.advanceTimersByTimeAsync( + attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS - Date.now() + 1, + ); + const current = restart + ? buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }) + : built; + const currentVenue = scaleVenue(current); + currentVenue.active.push(...venue.active); + currentVenue.history.push(...venue.history); + current.clientInstance.getTx.mockResolvedValue(null); + current.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: attempt.nonce, + }); + await current.provider.reviewScaleOrderGroups(); + const [reviewed] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(reviewed.rungs[0].cancelAttempt).toBeUndefined(); + const signsBefore = current.calls.filter( + (call) => call.function === '_signCancelOrder', + ).length; + const sendsBefore = current.clientInstance.sendTx.mock.calls.length; + expect(await current.provider.cancelOrder(params)).toMatchObject({ + success: true, + }); + expect( + current.calls + .filter((call) => call.function === '_signCancelOrder') + .slice(signsBefore) + .map((call) => call.params[2]), + ).toStrictEqual(['800']); + expect(current.clientInstance.sendTx).toHaveBeenCalledTimes( + sendsBefore + 1, + ); + expect(currentVenue.active).toStrictEqual([unrelated]); + expect( + (await current.provider.reviewScaleOrderGroups())[0].state, + ).toBe('terminal'); + expect(current.clientInstance.sendTx).toHaveBeenCalledTimes( + sendsBefore + 1, + ); + const [terminal] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(terminal.rungs[0].cancelAttempt?.txHash).not.toBe( + attempt.txHash, + ); + }, + ); - it('reports unavailable when the account read fails', async () => { - const infra = createMockInfrastructure(); - const logSpy = jest.spyOn(infra.debugLogger, 'log'); - const { provider, clientInstance } = buildProvider({ - platformDependencies: infra, - }); - clientInstance.getAccountByIndex.mockRejectedValue(new Error('down')); + it.each([ + 'acknowledged', + 'legacy', + 'unexpired', + 'nonce-consumed', + 'malformed-nonce', + 'lookup-failure', + 'missing-order', + ] as const)( + 'retains a Scale cancellation with %s evidence after expiry/restart', + async (condition) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const params = { + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale' as const, + }; + built.clientInstance.sendTx.mockResolvedValue({ + code: 200, + txHash: 'accepted', + }); + const canceling = built.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await canceling).toMatchObject({ success: false }); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const attempt = group.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing cancellation attempt'); + } + const raw = JSON.parse(JSON.stringify(group)) as { + rungs: { cancelAttempt?: { acknowledged?: boolean } }[]; + }; + for (const rung of raw.rungs) { + if (rung.cancelAttempt) { + if (condition === 'legacy') { + delete rung.cancelAttempt.acknowledged; + } else { + rung.cancelAttempt.acknowledged = condition === 'acknowledged'; + } + } + } + await infra.diskCache.setItem(key, JSON.stringify([raw])); + if (condition !== 'unexpired') { + await jest.advanceTimersByTimeAsync( + attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS - Date.now() + 1, + ); + } + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(restarted); + restarted.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: venue.active, + }); + restarted.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + restarted.clientInstance.getTx.mockResolvedValue(null); + let venueNonce = attempt.nonce; + if (condition === 'nonce-consumed') { + venueNonce += 2; + } + if (condition === 'malformed-nonce') { + venueNonce = Number.NaN; + } + restarted.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: venueNonce, + }); + if (condition === 'lookup-failure') { + restarted.clientInstance.getTx.mockRejectedValue( + new Error('lookup unavailable'), + ); + } + if (condition === 'missing-order') { + restarted.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + } + const repeated = restarted.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await repeated).toMatchObject({ success: false }); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toStrictEqual([]); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0] + .rungs[0].cancelAttempt, + ).toBeDefined(); + }, + ); - const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + it.each([ + ['before-send', false, 2], + ['accepted', true, 3], + ['acknowledgement-storage', true, 3], + ] as const)( + 'preserves cancellation completion evidence through %s interruption', + async (phase, retained, sends) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const second = venue.active.pop(); + if (!second) { + throw new Error('Missing Scale sibling'); + } + venue.history.push({ ...second, status: 'canceled' }); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const spy = jest.spyOn(infra.diskCache, 'setItem'); + const write = spy.getMockImplementation(); + let switched = false; + spy.mockImplementation(async (cacheKey: string, value: string) => { + if (cacheKey === key) { + const [group] = parseLighterScaleGroups(value); + const saved = group.rungs[0].cancelAttempt; + if ( + phase === 'acknowledgement-storage' && + saved?.acknowledged === true + ) { + throw new Error('ack storage unavailable'); + } + await write?.(cacheKey, value); + if (phase === 'before-send' && saved && !switched) { + switched = true; + built.fireReset(); + } + return; + } + await write?.(cacheKey, value); + }); + built.clientInstance.sendTx.mockImplementation(async () => { + if (phase === 'accepted') { + built.fireReset(); + } + return { code: 200, txHash: 'accepted' }; + }); + expect( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }), + ).toMatchObject({ success: false }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(sends); + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const attempt = group.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing interrupted attempt'); + } + await jest.advanceTimersByTimeAsync( + attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS - Date.now() + 1, + ); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const restored = scaleVenue(restarted); + restored.active.push(...venue.active); + restored.history.push(...venue.history); + restarted.clientInstance.getTx.mockResolvedValue(null); + restarted.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: attempt.nonce, + }); + await restarted.provider.reviewScaleOrderGroups(); + expect( + Boolean( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0] + .rungs[0].cancelAttempt, + ), + ).toBe(retained); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toStrictEqual([]); + }, + ); - expect(lock).toStrictEqual({ - status: 'unavailable', - providerId: 'lighter', - reason: 'provider_unavailable', - }); - expect(logSpy).toHaveBeenCalledWith( - '[LighterProvider] getMarginModeLock unavailable', - { symbol: 'BTC', error: 'down' }, + it.each([ + 'hash', + 'accountIndex', + 'apiKeyIndex', + 'nonce', + 'executed', + 'pending', + 'pending-final', + 'unknown', + ] as const)( + 'retains cancellation identity for mismatched/nonfailed %s', + async (field) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + built.clientInstance.sendTx.mockResolvedValue({ + code: 200, + txHash: 'accepted', + }); + const canceling = built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }); + await jest.advanceTimersByTimeAsync(10000); + expect(await canceling).toMatchObject({ success: false }); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const attempt = group.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing cancellation'); + } + const tx = { + code: 200, + hash: attempt.txHash, + accountIndex: 28, + apiKeyIndex: attempt.apiKeyIndex, + nonce: attempt.nonce, + status: Number(LIGHTER_TRANSACTION_STATUS.Failed), + }; + if (field === 'hash') { + tx.hash = 'deadbeef'; + } + if (field === 'accountIndex') { + tx.accountIndex += 1; + } + if (field === 'apiKeyIndex') { + tx.apiKeyIndex += 1; + } + if (field === 'nonce') { + tx.nonce += 1; + } + const statuses = { + executed: LIGHTER_TRANSACTION_STATUS.Executed, + pending: LIGHTER_TRANSACTION_STATUS.Pending, + 'pending-final': LIGHTER_TRANSACTION_STATUS.PendingFinal, + unknown: 99, + }; + if (field in statuses) { + tx.status = statuses[field as keyof typeof statuses]; + } + built.clientInstance.getTx.mockResolvedValue(tx); + await built.provider.reviewScaleOrderGroups(); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0] + .rungs[0].cancelAttempt, + ).toBeDefined(); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(4); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(2); + }, ); - }); - }); - describe('placeOrder', () => { - it.each(['cross', 'isolated'] as const)( - 'rejects explicit %s before signing', - async (marginMode) => { - const { provider, calls } = buildProvider(); + it('retains an unacknowledged Scale cancellation after mismatched evidence and expired absence', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const second = venue.active.pop(); + if (!second) { + throw new Error('Missing Scale sibling'); + } + venue.history.push({ ...second, status: 'canceled' }); + const unrelated = { + ...second, + orderIndex: 9999, + clientOrderIndex: second.clientOrderIndex + 1, + }; + venue.active.push(unrelated); + built.clientInstance.sendTx.mockRejectedValue( + new Error('cancel response lost'), + ); const params = { symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit' as const, - price: '90000', - leverage: 5, - marginMode, + orderId: String(placed.orderId), + orderType: 'scale' as const, }; - - const validation = await provider.validateOrder(params); - const result = await provider.placeOrder(params); - - expect(validation).toStrictEqual({ - isValid: false, - error: 'ORDER_MARGIN_MODE_UNSUPPORTED', + expect(await built.provider.cancelOrder(params)).toMatchObject({ + success: false, }); - expect(result.success).toBe(false); - expect(result.error).toBe('ORDER_MARGIN_MODE_UNSUPPORTED'); - expect(calls).toHaveLength(0); - }, - ); - - 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', + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const attempt = group.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing unacknowledged Scale cancellation'); + } + expect(attempt.acknowledged).toBe(false); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: 'deadbeef', + accountIndex: group.accountIndex, + apiKeyIndex: attempt.apiKeyIndex, + nonce: attempt.nonce, + status: Number(LIGHTER_TRANSACTION_STATUS.Failed), + }); + await built.provider.reviewScaleOrderGroups(); + const unknown = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + )[0].rungs[0].cancelAttempt; + await jest.advanceTimersByTimeAsync( + attempt.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS - Date.now() + 1, + ); + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: attempt.nonce, + }); + await built.provider.reviewScaleOrderGroups(); + const repeated = built.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await repeated).toMatchObject({ success: false }); + + const expected = { ...attempt }; + delete expected.acknowledged; + expect(unknown).toStrictEqual(expected); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0] + .rungs[0].cancelAttempt, + ).toStrictEqual(expected); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(3); + expect(venue.active).toStrictEqual([ + expect.objectContaining({ orderIndex: 800 }), + unrelated, + ]); }); - 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.each(['hash', 'expiry', 'nonce'] as const)( + 'refuses a malformed signed Scale cancellation %s before ledger append or send', + async (field) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const activeBefore = [...venue.active]; + const execute = jest + .mocked(built.bridge) + .execute.getMockImplementation(); + if (!execute) { + throw new Error('Missing signer implementation'); + } + jest + .mocked(built.bridge) + .execute.mockImplementation( + async ( + call: LighterWasmCall, + ): Promise> => { + const result = await execute(call); + if ( + call.function !== '_signCancelOrder' || + !('txInfo' in result) || + typeof result.txInfo !== 'string' + ) { + return result; + } + const wire = JSON.parse(result.txInfo) as Record< + string, + unknown + >; + if (field === 'expiry') { + wire.ExpiredAt = 0; + } + if (field === 'nonce') { + delete wire.Nonce; + } + return { + ...result, + ...(field === 'hash' ? { txHash: 'invalid' } : {}), + txInfo: JSON.stringify(wire), + } as LighterSignerResult; + }, + ); + const writes = jest.spyOn(infra.diskCache, 'setItem'); + expect( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }), + ).toMatchObject({ + success: false, + error: 'Invalid Lighter Scale cancellation identity', + }); + const cancelEntries = writes.mock.calls + .filter(([key]) => key === 'lighterNonceLedger:testnet:28:7') + .flatMap(([, value]) => { + const ledger = JSON.parse(value) as { + entries: { kind: number }[]; + }; + return ledger.entries.filter( + (entry) => entry.kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ); + }); + expect(cancelEntries).toStrictEqual([]); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect(venue.active).toStrictEqual(activeBefore); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + expect( + parseLighterScaleGroups( + await infra.diskCache.getItem(key), + )[0].rungs.every((rung) => rung.cancelAttempt === undefined), + ).toBe(true); + }, ); - }); - - it('does not claim or send an isolated-margin update when leverage is omitted', async () => { - const { provider, calls } = buildProvider(); - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', - }); - - expect(result.success).toBe(true); - expect( - calls.filter((call) => call.function === '_signUpdateLeverage'), - ).toHaveLength(0); - }); - 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.each(['owner', 'attempt'] as const)( + 'rejects Scale acknowledgement after its persisted %s is rewritten', + async (field) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + let rewritten: + | ReturnType[number] + | undefined; + built.clientInstance.sendTx.mockImplementation(async () => { + const groups = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const [group] = groups; + const attempt = group.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing persisted Scale cancellation'); + } + if (field === 'owner') { + group.accountIndex += 1; + } else { + attempt.nonce += 1; + } + rewritten = group; + await infra.diskCache.setItem(key, JSON.stringify(groups)); + return { code: 200, txHash: 'accepted' }; + }); + const cancellation = built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }); + await jest.advanceTimersByTimeAsync(10000); + expect(await cancellation).toMatchObject({ + success: false, + error: `Lighter Scale acknowledgement ${field === 'owner' ? 'ownership' : 'attempt'} mismatch`, + }); + expect(rewritten?.rungs[0].cancelAttempt).toBeDefined(); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0], + ).toStrictEqual(rewritten); + expect( + rewritten?.rungs[0].cancelAttempt?.acknowledged, + ).toBeUndefined(); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(3); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect(venue.active).toHaveLength(2); + }, + ); - 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', + it('retains Scale cancellation identity before transport and retries only exact failure', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.sendTx.mockImplementation( + async (_kind: number, txInfo: string) => { + const wire = JSON.parse(txInfo) as { Nonce: number }; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect( + group.rungs.some( + (rung) => rung.cancelAttempt?.nonce === wire.Nonce, + ), + ).toBe(true); + return { code: 200, txHash: '0xsent' }; + }, + ); + const params = { + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale' as const, + }; + const first = built.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await first).toMatchObject({ success: false }); + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const attempt = group.rungs[0].cancelAttempt; + if (!attempt) { + throw new Error('Missing durable Scale cancellation'); + } + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: attempt.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: attempt.nonce + 1, + status: LIGHTER_TRANSACTION_STATUS.Failed, + }); + await built.provider.reviewScaleOrderGroups(); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0] + .rungs[0].cancelAttempt, + ).toStrictEqual(attempt); + built.clientInstance.getTx.mockImplementation(async (hash: string) => + hash === attempt.txHash + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: attempt.nonce, + status: LIGHTER_TRANSACTION_STATUS.Failed, + } + : null, + ); + await built.provider.reviewScaleOrderGroups(); + const [failed] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(failed.rungs[0].cancelAttempt).toBeUndefined(); + expect(failed.rungs[1].cancelAttempt).toStrictEqual( + group.rungs[1].cancelAttempt, + ); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + return { code: 200, txHash: '0xsent' }; + }, + ); + const retrying = built.provider.cancelOrder(params); + await jest.advanceTimersByTimeAsync(10000); + expect(await retrying).toMatchObject({ success: false }); + expect( + built.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => [call.params[2], call.params[3]]), + ).toStrictEqual([ + ['800', 44], + ['801', 45], + ['800', 46], + ]); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(5); + expect(venue.active.map((row) => row.orderIndex)).toStrictEqual([801]); + }); + + it('bounds two-rung cancellation to one shared window and later settles without replay', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const stale = [...venue.active]; + const unrelated = { + ...stale[0], + orderIndex: 9999, + clientOrderIndex: + Math.max(...stale.map((row) => Number(row.clientOrderIndex))) + 1, + }; + venue.active.push(unrelated); + const readsBefore = + built.clientInstance.getActiveOrders.mock.calls.length; + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [...stale, unrelated], + }); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + let finished = false; + const canceling = built.provider + .cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }) + .then((result) => { + finished = true; + return result; + }); + await jest.advanceTimersByTimeAsync(9999); + expect(finished).toBe(false); + await jest.advanceTimersByTimeAsync(1); + expect(finished).toBe(true); + expect(await canceling).toMatchObject({ + success: false, + error: 'Lighter Scale cancellation remains unresolved', + }); + expect( + built.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['800', '801']); + expect( + built.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[3]), + ).toStrictEqual([44, 45]); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(4); + expect( + built.clientInstance.getActiveOrders.mock.calls.length - readsBefore, + ).toBeLessThanOrEqual(45); + expect(venue.active).toStrictEqual([unrelated]); + expect((await built.provider.getScaleOrderGroups())[0].state).toBe( + 'stopped', + ); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: venue.active, + }); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: venue.history, + }); + expect((await built.provider.reviewScaleOrderGroups())[0].state).toBe( + 'terminal', + ); + expect( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }), + ).toMatchObject({ success: true }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(4); }); - 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'); + it.each([ + 'nonce', + 'signer', + 'accepted-child', + 'leverage-nonce', + 'leverage-signer', + 'account', + 'accepted-reduce-only', + ] as const)( + 'refuses Scale metadata drift during %s and preserves exact accepted receipts', + async (boundary) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const drift = (): void => { + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, supportedPriceDecimals: 2 }, + ]); + }; + if (boundary === 'nonce' || boundary === 'leverage-nonce') { + const loadNonce = + built.clientInstance.getNextNonce.getMockImplementation() as + | LighterClientService['getNextNonce'] + | undefined; + if (!loadNonce) { + throw new Error('Missing nonce fixture'); + } + built.clientInstance.getNextNonce.mockImplementation( + async ( + ...args: Parameters + ) => { + const result = await loadNonce(...args); + drift(); + return result; + }, + ); + } else if (boundary === 'signer' || boundary === 'leverage-signer') { + const execute = jest.spyOn(built.bridge, 'execute'); + const sign = execute.getMockImplementation(); + if (!sign) { + throw new Error('Missing signer fixture'); + } + execute.mockImplementation(async (call: LighterWasmCall) => { + const result = await sign(call); + if ( + call.function === '_signCreateOrder' || + call.function === '_signUpdateLeverage' + ) { + drift(); + } + return result; + }); + } else if (boundary === 'account') { + const read = + built.clientInstance.getAccountByIndex.getMockImplementation() as + | LighterClientService['getAccountByIndex'] + | undefined; + if (!read) { + throw new Error('Missing account fixture'); + } + let reads = 0; + built.clientInstance.getAccountByIndex.mockImplementation( + async ( + ...args: Parameters + ) => { + const result = await read(...args); + reads += 1; + if (reads >= 2) { + drift(); + } + return result; + }, + ); + } else { + const send = built.clientInstance.sendTx.getMockImplementation() as + | LighterClientService['sendTx'] + | undefined; + if (!send) { + throw new Error('Missing venue fixture'); + } + built.clientInstance.sendTx.mockImplementation( + async (...args: Parameters) => { + const result = await send(...args); + drift(); + return result; + }, + ); + } + const result = await built.provider.placeOrder({ + ...intent, + ...(boundary.startsWith('leverage') ? { leverage: 4 } : {}), + ...(boundary === 'accepted-reduce-only' + ? { isBuy: false, reduceOnly: true } + : {}), + expectedScaleLadder: { + prices: ['90000', '100000'], + sizes: ['0.0005', '0.0005'], + totalSize: '0.001', + totalNotional: '95', + minimumBaseSize: BTC_MARKET.minBaseAmount, + minimumQuoteAmount: BTC_MARKET.minQuoteAmount, + sizeDecimals: 5, + }, + }); + expect(result).toMatchObject({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(venue.active).toHaveLength( + boundary.startsWith('accepted') ? 1 : 0, + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + boundary.startsWith('accepted') ? 1 : 0, + ); + expect(result.childOrderIds ?? []).toStrictEqual( + boundary.startsWith('accepted') ? ['800'] : [], + ); + const groups = await built.provider.getScaleOrderGroups(); + expect( + groups.flatMap((group) => group.childOrderIds ?? []), + ).toStrictEqual(boundary.startsWith('accepted') ? ['800'] : []); + }, + ); - const withTpsl = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', - takeProfitPrice: '100000', + it('keeps the displayed constraint when deleted during service fees and refuses real metadata drift', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - expect(withTpsl.success).toBe(false); - expect(withTpsl.error).toContain('updatePositionTPSL'); + scaleVenue(built); + const service = new TradingService(infra); + const release = createDeferred(); + service.setControllerDependencies({ + rewardsIntegrationService: { + resolveFee: async () => { + await release.promise; + return { + feeBips: 10, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }; + }, + } as never, + }); + const params: OrderParams = { + ...intent, + expectedScaleLadder: { + prices: ['90000', '100000'], + sizes: ['0.0005', '0.0005'], + totalSize: '0.001', + totalNotional: '95', + minimumBaseSize: BTC_MARKET.minBaseAmount, + minimumQuoteAmount: BTC_MARKET.minQuoteAmount, + sizeDecimals: 5, + }, + }; + const pending = service.placeOrder({ + provider: built.provider, + params, + context: createMockServiceContext(), + reportOrderToDataLake: jest.fn().mockResolvedValue({ success: true }), + }); + delete params.expectedScaleLadder; + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, minBaseAmount: '0.0003' }, + ]); + release.resolve(); + expect(await pending).toMatchObject({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('allows a below-maker-minimum market partial close without bumping it', async () => { - const { provider, calls } = buildProvider(); - const result = await provider.placeOrder({ + it.each([ + ['size-grid', { supportedSizeDecimals: 4 }], + ['base-minimum', { minBaseAmount: '0.0003' }], + ['quote-minimum', { minQuoteAmount: '20' }], + ])( + 'refuses displayed Scale preview drift in %s before signer or financial writes', + async (_name, changes) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + const preview = await built.provider.getScalePriceLadder({ + symbol: 'BTC', + minPrice: 90000, + maxPrice: 100000, + count: 2, + sizing: { size: '0.00123' }, + }); + if (preview.status !== 'ready' || !preview.sizingPreview) { + throw new Error('Expected a sized fixture preview'); + } + const params = { ...intent, size: '0.00123', leverage: 4 }; + Object.assign(params, { + expectedScaleLadder: { + prices: preview.prices, + ...preview.sizingPreview, + }, + }); + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, ...changes }, + ]); + + const result = await built.provider.placeOrder(params); + + expect(result).toMatchObject({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('places the exact displayed Scale preview when fresh metadata agrees', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const preview = await built.provider.getScalePriceLadder({ symbol: 'BTC', - isBuy: true, - size: '0.00001', - orderType: 'market', - reduceOnly: true, - isFullClose: true, - currentPrice: 90000, + minPrice: 90000, + maxPrice: 100000, + count: 2, + sizing: { size: '0.00123' }, + }); + if (preview.status !== 'ready' || !preview.sizingPreview) { + throw new Error('Expected a sized fixture preview'); + } + const params = { ...intent, size: '0.00123' }; + Object.assign(params, { + expectedScaleLadder: { + prices: preview.prices, + ...preview.sizingPreview, + }, }); + + const result = await built.provider.placeOrder(params); + expect(result.success).toBe(true); - expect( - calls.find((call) => call.function === '_signCreateOrder')?.params[3], - ).toBe('1'); + expect(venue.active.map((row) => row.price)).toStrictEqual( + preview.prices, + ); + expect(venue.active.map((row) => row.initialBaseAmount)).toStrictEqual( + preview.sizingPreview.sizes, + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); }); - it('does not apply maker minimums to a dust market full close', 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' }], + it.each([ + { prices: ['90000', '99999'] }, + { sizes: ['0.0006', '0.0004'] }, + { prices: ['90000'] }, + { sizes: new Array(2) }, + { totalSize: '0.002' }, + { totalNotional: '96' }, + { sizeDecimals: 4 }, + { minimumBaseSize: '0.0001' }, + { minimumQuoteAmount: '1' }, + { integratorFee: '0' }, + ])( + 'refuses altered expected Scale constraints %j before writes', + async (change) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + const params = { ...intent }; + Object.assign(params, { + expectedScaleLadder: { + prices: ['90000', '100000'], + sizes: ['0.0005', '0.0005'], + totalSize: '0.001', + totalNotional: '95', + minimumBaseSize: BTC_MARKET.minBaseAmount, + minimumQuoteAmount: BTC_MARKET.minQuoteAmount, + sizeDecimals: 5, + ...change, }, - ], + }); + expect(await built.provider.placeOrder(params)).toMatchObject({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('captures the approved Scale ladder before asynchronous placement reads', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const metadata = createDeferred<(typeof BTC_MARKET)[]>(); + built.clientInstance.getOrderBooks.mockReturnValueOnce(metadata.promise); + const params = { + ...intent, + expectedScaleLadder: { + prices: ['90000', '100000'], + sizes: ['0.0005', '0.0005'], + totalSize: '0.001', + totalNotional: '95', + minimumBaseSize: BTC_MARKET.minBaseAmount, + minimumQuoteAmount: BTC_MARKET.minQuoteAmount, + sizeDecimals: 5, + }, + }; + const pending = built.provider.placeOrder(params); + params.expectedScaleLadder.prices[0] = '1'; + params.expectedScaleLadder.sizes[0] = '1'; + params.size = '1'; + metadata.resolve([BTC_MARKET]); + expect((await pending).success).toBe(true); + expect(venue.active.map((row) => row.price)).toStrictEqual([ + '90000', + '100000', + ]); + expect(venue.active.map((row) => row.initialBaseAmount)).toStrictEqual([ + '0.0005', + '0.0005', + ]); + }); + + it('rechecks the expected Scale ladder inside the write lock before leverage or order signing', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + built.clientInstance.getOrderBooks.mockResolvedValueOnce([BTC_MARKET]); + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, minBaseAmount: '0.0003' }, + ]); + const result = await built.provider.placeOrder({ + ...intent, + leverage: 4, + expectedScaleLadder: { + prices: ['90000', '100000'], + sizes: ['0.0005', '0.0005'], + totalSize: '0.001', + totalNotional: '95', + minimumBaseSize: BTC_MARKET.minBaseAmount, + minimumQuoteAmount: BTC_MARKET.minQuoteAmount, + sizeDecimals: 5, + }, }); - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: false, - size: '0.00001', - orderType: 'market', - reduceOnly: true, - isFullClose: true, - currentPrice: 90000, + expect(result).toMatchObject({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', }); - expect(result.success).toBe(true); - const orderCall = calls.find( - (call) => call.function === '_signCreateOrder', - ); - expect(orderCall?.params[3]).toBe('1'); + expect( + built.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('rejects unknown markets', async () => { - const { provider } = buildProvider(); - const result = await provider.placeOrder({ - symbol: 'NOPE', - isBuy: true, - size: '1', - orderType: 'limit', - price: '1', + it('does not allow a Scale preview constraint on a basic order', async () => { + const built = buildProvider(); + const params = { ...intent, orderType: 'market' as const }; + Object.assign(params, { expectedScaleLadder: {} }); + expect(await built.provider.placeOrder(params)).toMatchObject({ + success: false, + error: 'ORDER_SCALE_PREVIEW_STALE', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Unknown Lighter market'); + expect(await built.provider.validateOrder(params)).toStrictEqual({ + isValid: false, + error: 'ORDER_SCALE_PREVIEW_STALE', + }); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - 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', + it('preserves executed Scale acceptance when later lookups disappear after expiry', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - expect(result.success).toBe(false); - expect(result.error).toContain('requires a price'); + scaleVenue(built); + built.clientInstance.sendTx.mockRejectedValue(new Error('lost response')); + await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const rung = group.rungs[0]; + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: rung.txHash, + nonce: rung.nonce, + apiKeyIndex: group.apiKeyIndex, + accountIndex: group.accountIndex, + status: 2, + }); + await built.provider.reviewScaleOrderGroups(); + const [accepted] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(accepted.rungs[0].state).toBe('accepted'); + expect(accepted.rungs[0].orderId).toBeUndefined(); + built.clientInstance.getTx.mockResolvedValue(null); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(rung.expiresAt) + 31_000); + try { + const [receipt] = await built.provider.reviewScaleOrderGroups(); + expect(receipt.state).toBe('stopped'); + expect(receipt.acceptedSize).toBe(rung.size); + const [retained] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(retained.rungs[0].state).toBe('accepted'); + expect(retained.rungs[0].nonAcceptance).toBeUndefined(); + } finally { + clock.mockRestore(); + } }); - 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' }], + it.each(['filled', 'canceled'] as const)( + 'preserves a %s Scale child and its fills across stale active rows', + async (terminalState) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + expect(placed.success).toBe(true); + const stale = venue.active.shift(); + if (!stale) { + throw new Error('Missing first Scale child'); + } + const filledSize = + terminalState === 'filled' ? stale.initialBaseAmount : '0.0001'; + venue.history.push({ + ...stale, + status: terminalState, + filledBaseAmount: filledSize, + remainingBaseAmount: terminalState === 'filled' ? '0' : '0.0004', + }); + await built.provider.reviewScaleOrderGroups(); + // Both endpoints now return the earlier snapshot, after durable terminal proof. + venue.history.length = 0; + venue.active.unshift(stale); + const [receipt] = await built.provider.reviewScaleOrderGroups(); + expect(receipt.filledSize).toBe(filledSize); + expect(receipt.childOrderIds).toStrictEqual(['801']); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(group.rungs[0]).toMatchObject({ + state: terminalState, + filledSize, + }); + expect( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }), + ).toMatchObject({ success: true }); + expect( + built.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['801']); + }, + ); + + it.each(['review', 'cancel'] as const)( + 'refuses Scale %s when a live row contradicts expired rejection', + async (operation) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + let submitted = 0; + built.clientInstance.sendTx.mockImplementation( + async (kind: number, txInfo: string) => { + const wire = JSON.parse(txInfo) as { createOrder?: boolean }; + if (wire.createOrder) { + submitted += 1; + if (submitted === 2) { + throw new Error('lost second response'); + } + } + await send?.(kind, txInfo); + return { code: 200, txHash: '0xsent' }; }, - ], - }); - 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', - }; + ); + const placed = await built.provider.placeOrder(intent); + expect(placed.success).toBe(false); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [pending] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(pending.rungs[0].state).toBe('resting'); + expect(pending.rungs[1].state).toBe('unknown'); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(pending.rungs[1].expiresAt) + 31_000); + try { + await built.provider.reviewScaleOrderGroups(); + const [rejected] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(rejected.rungs[1]).toMatchObject({ + state: 'rejected', + nonAcceptance: 'expired', + }); + const signed = built.calls + .filter((call) => call.function === '_signCreateOrder') + .at(-1); + if (!signed) { + throw new Error('Missing second signed child'); } - return realImplementation(call); - }, + venue.active.push({ + ...venue.active[0], + orderIndex: 801, + clientOrderIndex: Number(signed.params[2]), + initialBaseAmount: pending.rungs[1].size, + remainingBaseAmount: pending.rungs[1].size, + filledBaseAmount: '0', + price: pending.rungs[1].price, + }); + const sendsBefore = built.clientInstance.sendTx.mock.calls.length; + + const outcome = + operation === 'review' + ? await built.provider.reviewScaleOrderGroups().then( + () => ({ success: true }), + (error: unknown) => ({ + success: false, + error: + error instanceof Error ? error.message : String(error), + }), + ) + : await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }); + expect(outcome).toMatchObject({ + success: false, + error: 'Lighter Scale rejection conflicts with venue order', + }); + + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + sendsBefore, + ); + expect(venue.active.map((row) => row.orderIndex)).toStrictEqual([ + 800, 801, + ]); + const [retained] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + expect(retained.rungs[0].state).toBe('resting'); + expect(retained.rungs[1]).toMatchObject({ + state: 'rejected', + nonAcceptance: 'expired', + }); + const openOrders = await built.provider.getOpenOrders(); + expect(openOrders.map((order) => order.orderId)).toStrictEqual([ + '800', + '801', + ]); + expect(openOrders[0].strategyGroupId).toBe(retained.groupId); + expect(openOrders[1].strategyGroupId).toBeUndefined(); + const [listed] = await built.provider.getScaleOrderGroups(); + expect(listed.childOrderIds).toStrictEqual(['800']); + expect(listed.acceptedSize).toBe('0.0005'); + } finally { + clock.mockRestore(); + } + }, + ); + + it('preserves cumulative Scale fills when a live child row goes backwards', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + const stale = { ...venue.active[0] }; + venue.active[0] = { + ...stale, + filledBaseAmount: '0.0002', + remainingBaseAmount: '0.0003', + }; + expect( + (await built.provider.reviewScaleOrderGroups())[0].filledSize, + ).toBe('0.0002'); + venue.active[0] = stale; + expect( + (await built.provider.reviewScaleOrderGroups())[0].filledSize, + ).toBe('0.0002'); + }); + + it.each(['missing', 'replaced'] as const)( + 'cancels an owned live Scale group after its local read authority is %s', + async (authority) => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const originalVenue = scaleVenue(first); + const placed = await first.provider.placeOrder(intent); + expect(placed.success).toBe(true); + const registeredKey = + authority === 'missing' ? '9c'.repeat(40) : 'ab'.repeat(40); + const restarted = buildProvider({ + registeredKey, + platformDependencies: infra, + }); + const recoveredKey = + authority === 'missing' ? 'de'.repeat(40) : '9c'.repeat(40); + Object.assign(restarted.bridge, { + getStoredKeyIndices: async () => (authority === 'missing' ? [] : [7]), + getRecoverableKeyIndices: async () => [7], + }); + const createClient = jest.spyOn(restarted.bridge, 'createClient'); + const createClientImplementation = createClient.getMockImplementation(); + createClient.mockImplementation(async (params) => { + const result = await createClientImplementation?.(params); + if (!result) { + throw new Error('Missing fixture client'); + } + return { ...result, pk: recoveredKey }; + }); + const venue = scaleVenue(restarted); + venue.active.push(...originalVenue.active); + const send = restarted.clientInstance.sendTx.getMockImplementation(); + restarted.clientInstance.sendTx.mockImplementation( + async (kind: number, txInfo: string) => { + const wire = JSON.parse(txInfo) as { changePubKey?: boolean }; + if (wire.changePubKey) { + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex: 7, + nonce: 1, + publicKey: registeredKey, + }, + { + accountIndex: 28, + apiKeyIndex: Number( + restarted.calls + .filter((call) => call.function === '_createClient') + .at(-1)?.params[3], + ), + nonce: 1, + publicKey: recoveredKey, + }, + ], + }); + } + await send?.(kind, txInfo); + return { code: 200, txHash: '0xsent' }; + }, + ); + const canceled = await restarted.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }); + expect(canceled).toMatchObject({ success: true }); + expect( + restarted.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['800', '801']); + expect( + restarted.calls.filter( + (call) => call.function === '_signChangePubKey', + ), + ).toHaveLength(1); + expect(venue.active).toStrictEqual([]); + }, + ); + + it('does not treat a Scale registration lookup failure as missing local authority', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + const placed = await built.provider.placeOrder(intent); + const signedBefore = built.calls.filter((call) => + call.function.startsWith('_sign'), + ).length; + built.clientInstance.getApiKeys.mockRejectedValue( + new Error('registration unavailable'), ); - const result = await provider.placeOrder({ + const canceled = await built.provider.cancelOrder({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', - leverage: 10, + orderId: String(placed.orderId), + orderType: 'scale', }); - 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); + expect(canceled).toMatchObject({ + success: false, + error: 'registration unavailable', + }); + expect( + built.calls.filter((call) => call.function.startsWith('_sign')), + ).toHaveLength(signedBefore); }); - 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.each(['failed', 'expired'] as const)( + 'reviews %s proof before reconciliation without trapping the ledger', + async (proof) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(built); + built.clientInstance.sendTx.mockRejectedValue( + new Error('lost response'), + ); + const result = await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const rung = group.rungs[0]; + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(rung.expiresAt) + 31_000); + if (proof === 'failed') { + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: rung.txHash, + nonce: rung.nonce, + apiKeyIndex: 7, + accountIndex: 28, + status: 0, + }); + } + try { + const reviewed = await built.provider.reviewScaleOrderGroups(); + expect(reviewed[0].state).toBe('terminal'); + await built.provider.reconcileRecoveredDispatches(); + const canceled = await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }); + expect(canceled.success).toBe(true); + scaleVenue(built); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + } finally { + clock.mockRestore(); + } + }, + ); + + it('retires an acknowledged invisible child after restart and expiry without financial setup', async () => { + jest.useFakeTimers(); + try { + const infra = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + first.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + first.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + const placing = first.provider.placeOrder(intent); + await jest.advanceTimersByTimeAsync(10000); + const result = await placing; + expect(result).toMatchObject({ + success: false, + submittedSize: '0.0005', + }); + expect(first.clientInstance.sendTx).toHaveBeenCalledTimes(1); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(restarted); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.rungs[0].expiresAt) + 31_000); + try { + expect( + ( + await restarted.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(true); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + } finally { + clock.mockRestore(); + } + } finally { + jest.useRealTimers(); + } }); - 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', + it('observes delayed placement and cancellation rows within the settle window', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + let reads = 0; + built.clientInstance.getActiveOrders.mockImplementation(async () => { + reads += 1; + return { code: 200, orders: reads % 3 === 0 ? [...venue.active] : [] }; }); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(true); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(2); + }); + + it('persists a rejected later row while preserving the resting first child', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + if (venue.active.length === 2) { + const row = venue.active.pop(); + if (row) { + venue.history.push({ ...row, status: 'rejected' }); + } + } + return { code: 200, txHash: '0xsent' }; + }, + ); + const result = await built.provider.placeOrder(intent); expect(result.success).toBe(false); - expect(result.error).toContain('signer bridge'); + const groups = await built.provider.reviewScaleOrderGroups(); + expect(groups[0].acceptedSize).toBe('0.0005'); + expect(groups[0].state).toBe('stopped'); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(true); }); - }); - describe('cancelOrder', () => { - it('signs and submits a cancel', async () => { - const { provider, clientInstance, calls } = buildProvider(); - const result = await provider.cancelOrder({ - orderId: '555', - symbol: 'BTC', + it('returns real ordinary rows when optional attribution is corrupt', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); + scaleVenue(built); + const result = await built.provider.placeOrder(intent); 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'), + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + await infra.diskCache.setItem(key, '{broken'); + const orders = await built.provider.getOpenOrders(); + expect(orders).toHaveLength(2); + expect(orders[0].strategyGroupId).toBeUndefined(); + await expect(built.provider.getScaleOrderGroups()).rejects.toThrow( + 'journal', ); }); - it('rejects unknown markets', async () => { - const { provider } = buildProvider(); - const result = await provider.cancelOrder({ - orderId: '1', - symbol: 'NOPE', + it('rejects excessive reduce-only leverage before financial setup', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const params = { + ...intent, + isBuy: false, + reduceOnly: true, + leverage: 100, + }; + expect((await built.provider.validateOrder(params)).isValid).toBe(false); + expect((await built.provider.placeOrder(params)).success).toBe(false); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('cancels an already terminal group after restart without signer setup', async () => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - expect(result.success).toBe(false); + scaleVenue(first); + const result = await first.provider.placeOrder(intent); + const cancel = { + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale' as const, + }; + expect((await first.provider.cancelOrder(cancel)).success).toBe(true); + const restarted = buildProvider({ platformDependencies: infra }); + expect((await restarted.provider.cancelOrder(cancel)).success).toBe(true); + expect(restarted.calls).toStrictEqual([]); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); }); - }); - describe('price streaming', () => { - class FakeWebSocket implements LighterWebSocketLike { - static instances: FakeWebSocket[] = []; + it.each(['0', '0.0001', undefined])( + 'tracks actual accepted exposure with fill %s through TradingService', + async (fill) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + for (const row of venue.active) { + if (fill === undefined) { + delete row.filledBaseAmount; + } else { + row.filledBaseAmount = fill; + } + } + return { code: 200, txHash: '0xsent' }; + }, + ); + const track = jest.spyOn(infra.metrics, 'trackPerpsEvent'); + const service = new TradingService(infra); + const result = await service.placeOrder({ + provider: built.provider, + params: intent, + context: createMockServiceContext(), + reportOrderToDataLake: jest.fn().mockResolvedValue({ success: true }), + }); + expect(result.success).toBe(true); + expect(result.filledSize).toBe(fill === '0.0001' ? '0.0002' : fill); + const size = fill === '0.0001' ? 0.0002 : 0.001; + expect(track).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + order_size: size, + order_value: size * 95000, + }), + ); + const partial = track.mock.calls.filter( + (call) => call[1]?.status === 'partially_filled', + ); + expect(partial).toHaveLength(fill === '0.0001' ? 1 : 0); + }, + ); - readyState = 0; - - sent: string[] = []; - - onopen: (() => void) | null = null; - - onmessage: ((event: { data: unknown }) => void) | null = null; + it('validates existing reduce-only reservations before any financial setup', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + venue.active.push({ + orderIndex: 700, + clientOrderIndex: 1000, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.0995', + remainingBaseAmount: '0.0995', + price: '100000', + isAsk: true, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: Date.now(), + }); + const params = { ...intent, isBuy: false, reduceOnly: true }; + expect((await built.provider.validateOrder(params)).isValid).toBe(false); + expect((await built.provider.placeOrder(params)).success).toBe(false); + expect( + built.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); - onclose: (() => void) | null = null; + it('fails closed without registered reservation read authority', async () => { + const built = buildProvider(); + const params = { ...intent, isBuy: false, reduceOnly: true }; + expect((await built.provider.validateOrder(params)).isValid).toBe(false); + expect((await built.provider.placeOrder(params)).success).toBe(false); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + built.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + }); - onerror: (() => void) | null = null; + it('uses fresh terminal history when a prior reduce-only child filled', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const spy = jest.spyOn(built.bridge, 'execute'); + const execute = spy.getMockImplementation(); + spy.mockImplementation(async (call) => { + if (call.function === '_signCreateOrder' && venue.active.length === 1) { + const row = venue.active.shift(); + if (row) { + venue.history.push({ + ...row, + status: 'filled', + filledBaseAmount: row.initialBaseAmount, + remainingBaseAmount: '0', + }); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.0995' }], + }, + ], + }); + } + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }); + expect( + ( + await built.provider.placeOrder({ + ...intent, + isBuy: false, + reduceOnly: true, + }) + ).success, + ).toBe(true); + expect(venue.history[0].status).toBe('filled'); + expect(venue.active).toHaveLength(1); + }); - url: string; + it('cancels known resting siblings even when an earlier accepted row is absent', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const result = await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + group.rungs[0].state = 'accepted'; + delete group.rungs[0].orderId; + delete group.rungs[0].filledSize; + venue.active.shift(); + await infra.diskCache.setItem(key, JSON.stringify([group])); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(false); + expect( + built.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['801']); + }); - constructor(url: string) { - this.url = url; - FakeWebSocket.instances.push(this); + it('waits for lagged cancellation without signing duplicate cancels', async () => { + jest.useFakeTimers(); + try { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const result = await built.provider.placeOrder(intent); + const send = built.clientInstance.sendTx.getMockImplementation(); + let lagged: LighterApiOrder | undefined; + let reads = 0; + const snapshots: number[][] = []; + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + lagged = venue.active[0]; + reads = 0; + await send?.(...args); + return { code: 200, txHash: '0xsent' }; + }, + ); + built.clientInstance.getActiveOrders.mockImplementation(async () => { + reads += 1; + const orders = + lagged && reads < 3 ? [lagged, ...venue.active] : [...venue.active]; + snapshots.push(orders.map((row) => row.orderIndex)); + return { code: 200, orders }; + }); + const canceling = built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }); + await jest.advanceTimersByTimeAsync(750); + expect(await canceling).toMatchObject({ success: true }); + expect(snapshots).toContainEqual([801]); + expect(venue.history.map((row) => row.orderIndex)).toStrictEqual([ + 800, 801, + ]); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalled(); + expect( + built.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => [call.params[2], call.params[3]]), + ).toStrictEqual([ + ['800', 44], + ['801', 45], + ]); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(4); + expect((await built.provider.reviewScaleOrderGroups())[0].state).toBe( + 'terminal', + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(4); + } finally { + jest.useRealTimers(); } + }); - send = (data: string): void => { - this.sent.push(data); - }; + it.each([ + 'different-order', + 'active-duplicate', + 'history-duplicate', + 'signed-intent', + 'invalid-fill', + ] as const)( + 'refuses conflicting Scale overlap for %s without financial dispatch', + async (conflict) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + const [first, second] = venue.active.splice(0); + venue.history.push( + { ...first, status: 'canceled' }, + { ...second, status: 'canceled' }, + ); + venue.active.push(second); + if (conflict === 'different-order') { + venue.history[1].orderIndex = 9999; + } + if (conflict === 'active-duplicate') { + venue.active.push({ ...second }); + } + if (conflict === 'history-duplicate') { + venue.history.push({ ...venue.history[1] }); + } + if (conflict === 'signed-intent') { + venue.history[1].price = '1'; + } + if (conflict === 'invalid-fill') { + venue.active[0].filledBaseAmount = '-1'; + } + const errors = { + 'different-order': 'Ambiguous Lighter Scale order identity', + 'active-duplicate': 'Ambiguous Lighter Scale order identity', + 'history-duplicate': 'Ambiguous Lighter Scale order identity', + 'signed-intent': 'Lighter Scale order does not match signed intent', + 'invalid-fill': 'Invalid Lighter Scale fill quantity', + }; + await expect(built.provider.reviewScaleOrderGroups()).rejects.toThrow( + errors[conflict], + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + }, + ); - close = (): void => { - this.readyState = 3; - this.onclose?.(); + it('preserves cumulative Scale fills through overlapping snapshots', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + const [first, second] = venue.active; + venue.active[1] = { + ...second, + filledBaseAmount: '0.0002', + remainingBaseAmount: '0.0003', }; + expect( + (await built.provider.reviewScaleOrderGroups())[0].filledSize, + ).toBe('0.0002'); + venue.active.splice(0, 2, { ...second, filledBaseAmount: '0.0001' }); + venue.history.push( + { ...first, status: 'canceled' }, + { ...second, status: 'canceled', filledBaseAmount: '0' }, + ); + expect( + (await built.provider.reviewScaleOrderGroups())[0].filledSize, + ).toBe('0.0002'); + venue.active.length = 0; + venue.history[1].filledBaseAmount = '0.0002'; + expect( + (await built.provider.reviewScaleOrderGroups())[0].filledSize, + ).toBe('0.0002'); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + }); - open = (): void => { - this.readyState = 1; - this.onopen?.(); - }; + it.each([7, 8])( + 'recovers fresh exact accepted rows into acknowledgment after restart in slot %s', + async (slot) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, + ); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + apiKeyIndex: slot, + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { accountIndex: 28, apiKeyIndex: slot, publicKey: '9c'.repeat(40) }, + ], + }); + const nextVenue = scaleVenue(restarted); + nextVenue.active.push(...venue.active); + restarted.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: 1000, + }); + const groups = await restarted.provider.reviewScaleOrderGroups(); + expect(groups[0].acceptedSize).toBe('0.0005'); + expect(restarted.clientInstance.getNextNonce).toHaveBeenCalledWith( + 28, + 7, + ); + const recovered = await restarted.provider.getRecoveredDispatches(); + expect(recovered).toStrictEqual([ + expect.objectContaining({ + outcome: 'succeeded', + evidence: 'fresh-exact-scale-child', + }), + ]); + expect((await restarted.provider.placeOrder(intent)).success).toBe( + false, + ); + await restarted.provider.acknowledgeRecoveredDispatch( + recovered[0].recoveryId, + ); + expect((await restarted.provider.placeOrder(intent)).success).toBe( + true, + ); + }, + ); - receive = (message: unknown): void => { - this.onmessage?.({ data: JSON.stringify(message) }); + it.each([ + 'unadvanced', + 'mismatched-hash', + 'pending-hash', + 'missing-row', + 'wrong-side', + 'wrong-kind', + 'wrong-nonce', + 'wrong-expiry', + 'wrong-intent', + 'wrong-owner', + 'wrong-ledger-hash', + 'wrong-slot', + ] as const)('keeps recovery pending with %s evidence', async (control) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost'); + }, + ); + await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const rung = group.rungs[0]; + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ledger = JSON.parse( + String(await infra.diskCache.getItem(ledgerKey)), + ) as { + entries: { + kind: number; + nonce: number; + expiresAt: number; + intent: string; + owner: string | null; + txHash: string | null; + }[]; }; - } - - const fakeCtor = FakeWebSocket as unknown as LighterWebSocketCtor; - - beforeEach(() => { - FakeWebSocket.instances = []; + if (control === 'wrong-owner') { + ledger.entries[0].owner = 'another-operation'; + } + if (control === 'wrong-ledger-hash') { + ledger.entries[0].txHash = 'aa'; + } + if (control === 'wrong-slot') { + group.apiKeyIndex = 8; + await infra.diskCache.setItem(key, JSON.stringify([group])); + } + if (control === 'wrong-kind') { + ledger.entries[0].kind = 15; + } + if (control === 'wrong-nonce') { + ledger.entries[0].nonce += 1; + } + if (control === 'wrong-expiry') { + ledger.entries[0].expiresAt += 1; + } + if (control === 'wrong-intent') { + ledger.entries[0].intent += ':other'; + } + await infra.diskCache.setItem(ledgerKey, JSON.stringify(ledger)); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: control === 'unadvanced' ? rung.nonce : 1000, + }); + if (control === 'mismatched-hash' || control === 'pending-hash') { + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: control === 'mismatched-hash' ? 'ff' : rung.txHash, + nonce: rung.nonce, + apiKeyIndex: 7, + accountIndex: 28, + status: control === 'pending-hash' ? 1 : 2, + }); + } + if (control === 'missing-row') { + venue.active.length = 0; + } + if (control === 'wrong-side') { + venue.active[0].isAsk = true; + } + const reviewError = await built.provider.reviewScaleOrderGroups().then( + () => undefined, + (error: unknown) => + error instanceof Error ? error.message : String(error), + ); + expect(reviewError).toBe( + control === 'wrong-side' + ? 'Lighter Scale order does not match signed intent' + : undefined, + ); + const recovered = await built.provider.getRecoveredDispatches(); + expect(recovered).not.toContainEqual( + expect.objectContaining({ evidence: 'fresh-exact-scale-child' }), + ); + expect((await built.provider.placeOrder(intent)).success).toBe(false); }); - 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.each([ + 'unexpired', + 'incomplete-history', + 'mismatched-hash', + 'unscoped-active', + 'unscoped-history', + ] as const)( + 'does not abandon acknowledged absence with %s', + async (control) => { + jest.useFakeTimers(); + try { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + const placing = built.provider.placeOrder(intent); + await jest.advanceTimersByTimeAsync(9999); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + const placed = await placing; + expect(placed).toMatchObject({ + success: false, + submittedSize: '0.0005', + }); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue( + control === 'unexpired' + ? Number(group.rungs[0].expiresAt) - 1 + : Number(group.rungs[0].expiresAt) + 31_000, + ); + if (control === 'incomplete-history') { + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + nextCursor: 'more', + }); + } + if (control === 'mismatched-hash') { + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: 'ff', + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.rungs[0].nonce, + status: 2, + }); + } + if (control === 'unscoped-active' || control === 'unscoped-history') { + const row: LighterApiOrder = { + orderIndex: 700, + clientOrderIndex: 1000, + marketIndex: 1, + ownerAccountIndex: 29, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + price: '100000', + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: Date.now(), + }; + const endpoint = + control === 'unscoped-active' + ? built.clientInstance.getActiveOrders + : built.clientInstance.getInactiveOrders; + endpoint.mockResolvedValue({ code: 200, orders: [row] }); + } + try { + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(placed.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(false); + expect((await built.provider.getScaleOrderGroups())[0].state).toBe( + 'unknown', + ); + expect( + built.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(0); + } finally { + clock.mockRestore(); + } + } finally { + jest.useRealTimers(); + } + }, + ); - 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, + it('retries terminal fresh-row acceptance after a ledger persistence failure', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - - const socket = FakeWebSocket.instances[0]; - socket.open(); - expect(socket.sent).toContainEqual( - JSON.stringify({ type: 'subscribe', channel: 'market_stats/all' }), + const venue = scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost'); + }, ); - - socket.receive({ - type: 'subscribed/market_stats', - channel: 'market_stats:all', - market_stats: { '1': wsStat('BTC', 1, '63000.5') }, - timestamp: 123, + await built.provider.placeOrder(intent); + const row = venue.active.shift(); + if (!row) { + throw new Error('Missing accepted fixture row'); + } + venue.history.push({ + ...row, + status: 'filled', + filledBaseAmount: row.initialBaseAmount, + remainingBaseAmount: '0', }); - expect(callback).toHaveBeenCalledWith([ + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: 1000, + }); + const setter = jest.spyOn(infra.diskCache, 'setItem'); + const write = setter.getMockImplementation(); + let failed = false; + setter.mockImplementation(async (key, value) => { + if ( + !failed && + key.startsWith('lighterNonceLedger:') && + value.includes('fresh-exact-scale-child') + ) { + failed = true; + throw new Error('ledger unavailable'); + } + await write?.(key, value); + }); + await expect(built.provider.reviewScaleOrderGroups()).rejects.toThrow( + 'ledger unavailable', + ); + expect((await built.provider.getScaleOrderGroups())[0].state).toBe( + 'terminal', + ); + await built.provider.reviewScaleOrderGroups(); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([ expect.objectContaining({ - symbol: 'BTC', - price: '63000.5', - markPrice: '63000.5', - timestamp: 123, + outcome: 'succeeded', + evidence: 'fresh-exact-scale-child', }), ]); + }); - socket.receive({ - type: 'update/market_stats', - channel: 'market_stats:all', - market_stats: { '2': wsStat('SOL', 2, '75.1') }, - timestamp: 456, + it('rejects unavailable leverage metadata for reduce-only before setup', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [], }); - expect(callback).toHaveBeenLastCalledWith([ - expect.objectContaining({ symbol: 'SOL', price: '75.1' }), - ]); - unsubscribe(); - await provider.disconnect(); + const params = { ...intent, isBuy: false, reduceOnly: true, leverage: 5 }; + expect((await built.provider.validateOrder(params)).isValid).toBe(false); + expect((await built.provider.placeOrder(params)).success).toBe(false); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('does not log every price-stream frame', async () => { + it('rechecks reservations that change during child signing', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const params = { ...intent, isBuy: false, reduceOnly: true }; + expect((await built.provider.validateOrder(params)).isValid).toBe(true); + const spy = jest.spyOn(built.bridge, 'execute'); + const execute = spy.getMockImplementation(); + spy.mockImplementation(async (call) => { + const signed = await execute?.(call); + if (!signed) { + throw new Error('Missing signer'); + } + if (call.function === '_signCreateOrder') { + venue.active.push({ + orderIndex: 700, + clientOrderIndex: 1000, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.0995', + remainingBaseAmount: '0.0995', + price: '100000', + isAsk: true, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 1, + status: 'open', + orderExpiry: 0, + timestamp: Date.now(), + }); + } + return signed; + }); + expect((await built.provider.placeOrder(params)).success).toBe(false); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + (await built.provider.getScaleOrderGroups())[0].submittedSize, + ).toBe('0'); + }); + + it('retains failed proof when ledger retirement fails after group review', async () => { const infra = createMockInfrastructure(); - const { provider } = buildProvider({ - webSocketCtor: fakeCtor, + const built = buildProvider({ + registeredKey: '9c'.repeat(40), platformDependencies: infra, }); - const unsubscribe = provider.subscribeToPrices({ - symbols: [], - callback: jest.fn(), + scaleVenue(built); + built.clientInstance.sendTx.mockRejectedValue(new Error('lost')); + await built.provider.placeOrder(intent); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [group] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.rungs[0].txHash, + nonce: group.rungs[0].nonce, + accountIndex: 28, + apiKeyIndex: 7, + status: 0, + }); + const setter = jest.spyOn(infra.diskCache, 'setItem'); + const write = setter.getMockImplementation(); + let failed = false; + setter.mockImplementation(async (keyName, value) => { + if (!failed && keyName.startsWith('lighterNonceLedger:')) { + failed = true; + throw new Error('ledger unavailable'); + } + await write?.(keyName, value); }); - const socket = FakeWebSocket.instances[0]; - socket.open(); + await expect(built.provider.reviewScaleOrderGroups()).rejects.toThrow( + 'ledger unavailable', + ); + expect( + parseLighterScaleGroups(await infra.diskCache.getItem(key))[0].rungs[0] + .nonAcceptance, + ).toBe('failed'); + await built.provider.reviewScaleOrderGroups(); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([ + expect.objectContaining({ outcome: 'failed' }), + ]); + scaleVenue(built); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + }); - socket.receive({ - type: 'subscribed/market_stats', - market_stats: { '1': wsStat('BTC', 1, '63000.5') }, - }); - socket.receive({ - type: 'update/market_stats', - market_stats: { '1': wsStat('BTC', 1, '63001.5') }, + it('validates a complete scale before signer setup', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + expect(await built.provider.validateOrder(intent)).toStrictEqual({ + isValid: true, }); + expect(built.calls).toStrictEqual([]); + }); - expect(infra.debugLogger.log).not.toHaveBeenCalledWith( - expect.stringContaining('[LighterProvider] price stream cycle='), + it('submits each exact scale rung with a distinct nonce', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getActiveOrders.mockImplementation(async () => ({ + code: 200, + orders: built.calls + .filter((call) => call.function === '_signCreateOrder') + .map((call, index) => ({ + orderIndex: 800 + index, + clientOrderIndex: Number(call.params[2]), + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: String(Number(call.params[3]) / 100000), + remainingBaseAmount: String(Number(call.params[3]) / 100000), + filledBaseAmount: '0', + price: String(Number(call.params[4]) / 10), + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: Date.now(), + })), + })); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(true); + expect(result.submittedSize).toBe('0.001'); + expect(result.orderId).toMatch(/^lighter-scale:/u); + const signed = built.calls.filter( + (call) => call.function === '_signCreateOrder', ); - unsubscribe(); - await provider.disconnect(); + expect(signed).toHaveLength(2); + expect(signed.map((call) => call.params.slice(3, 5))).toStrictEqual([ + ['50', '900000'], + ['50', '1000000'], + ]); + expect(new Set(signed.map((call) => call.params.at(-1))).size).toBe(2); }); - 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(), + it('retains real rows and exact group cancellation across provider restart', async () => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - const socket = FakeWebSocket.instances[0]; - socket.open(); - socket.receive({ - type: 'subscribed/market_stats', - market_stats: { '1': wsStat('BTC', 1, '63000.5') }, + const venue = scaleVenue(first); + const result = await first.provider.placeOrder(intent); + expect(result.success).toBe(true); + expect(await first.provider.getOpenOrders()).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ strategyGroupId: result.orderId }), + ]), + ); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + apiKeyIndex: 8, }); - // 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') }, + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { accountIndex: 28, apiKeyIndex: 8, publicKey: '9c'.repeat(40) }, + ], }); - - const late = jest.fn(); - const unsubscribeLate = provider.subscribeToPrices({ - symbols: ['BTC'], - callback: late, + const restartedVenue = scaleVenue(restarted); + restartedVenue.active.push(...venue.active); + expect(await restarted.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ groupId: result.orderId, apiKeyIndex: 7 }), + ]); + const canceled = await restarted.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', }); - expect(late).toHaveBeenCalledWith([ - expect.objectContaining({ symbol: 'BTC' }), + expect(canceled.success).toBe(true); + expect( + restarted.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect( + restarted.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['800', '801']); + expect(await restarted.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ + state: 'terminal', + childOrderIds: [], + acceptedSize: '0.001', + }), ]); - unsubscribeFirst(); - unsubscribeLate(); - await provider.disconnect(); }); - it('streams user_stats and account_all_positions into their subscribers', async () => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeCtor, - registeredKey: 'a'.repeat(80), + it.each(['unknown', 'submitted'] as const)( + 'preserves crash ownership when persisting %s fails', + async (failedState) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const venue = scaleVenue(built); + const setter = jest.spyOn(infra.diskCache, 'setItem'); + const write = setter.getMockImplementation(); + let failed = false; + setter.mockImplementation(async (key, value) => { + if ( + !failed && + key.startsWith('lighterScaleOrders:') && + value.includes(`"state":"${failedState}"`) + ) { + failed = true; + throw new Error('disk failed'); + } + await write?.(key, value); + }); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect(failed).toBe(true); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + failedState === 'unknown' ? 0 : 1, + ); + expect(result.submittedSize).toBe( + failedState === 'unknown' ? '0' : '0.0005', + ); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const recoveredVenue = scaleVenue(restarted); + recoveredVenue.active.push(...venue.active); + const groups = await restarted.provider.reviewScaleOrderGroups(); + expect(groups).toStrictEqual([ + expect.objectContaining({ + state: failedState === 'unknown' ? 'terminal' : 'stopped', + acceptedSize: failedState === 'unknown' ? '0' : '0.0005', + }), + ]); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateOrder', + ), + ).toHaveLength(0); + }, + ); + + it('reconciles a crash after nonce append but before the durable dispatch transition', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - clientInstance.getOrderBookDetails.mockResolvedValue({ + scaleVenue(built); + const setter = jest.spyOn(infra.diskCache, 'setItem'); + const write = setter.getMockImplementation(); + let unavailable = false; + setter.mockImplementation(async (key, value) => { + if ( + key.startsWith('lighterScaleOrders:') && + value.includes('"state":"unknown"') + ) { + unavailable = true; + } + if (key.startsWith('lighterScaleOrders:') && unavailable) { + throw new Error('persistent disk outage'); + } + await write?.(key, value); + }); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + setter.mockImplementation(async (key, value) => { + await write?.(key, value); + }); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(restarted); + restarted.clientInstance.getNextNonce.mockResolvedValue({ code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - { - ...BTC_MARKET, - symbol: 'SOL', - marketId: 2, - lastTradePrice: 100, - minInitialMarginFraction: 400, - maintenanceMarginFraction: 240, - }, - ], + nonce: 1000, }); - const accountCallback = jest.fn(); - const positionsCallback = jest.fn(); - const unsubscribeAccount = provider.subscribeToAccount({ - callback: accountCallback, + const canceled = await restarted.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', }); - const unsubscribePositions = provider.subscribeToPositions({ - callback: positionsCallback, + expect(canceled.success).toBe(true); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await restarted.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ state: 'terminal', submittedSize: '0' }), + ]); + }); + + it('refuses full unresolved inventory before setup and reclaims only a terminal group', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - // 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)); + scaleVenue(built); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const [original] = parseLighterScaleGroups( + await infra.diskCache.getItem(key), + ); + const groups = Array.from({ length: 64 }, (_value, index) => ({ + ...original, + groupId: `lighter-scale:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:${1000 + index * 2}`, + rungs: original.rungs.map((rung, rungIndex) => ({ + ...rung, + clientOrderId: 1000 + index * 2 + rungIndex, + orderId: String(10000 + index * 2 + rungIndex), + })), + })); + await infra.diskCache.setItem(key, JSON.stringify(groups)); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(restarted); + const refused = await restarted.provider.placeOrder(intent); + expect(refused.success).toBe(false); + expect(refused.error).toContain('ownership is full'); + expect(restarted.calls).toStrictEqual([]); + for (const rung of groups[0].rungs) { + rung.state = 'canceled'; + } + await infra.diskCache.setItem(key, JSON.stringify(groups)); + expect((await restarted.provider.placeOrder(intent)).success).toBe(true); + const retained = await restarted.provider.getScaleOrderGroups(); + expect(retained).toHaveLength(64); + expect( + retained.some((group) => group.groupId === groups[0].groupId), + ).toBe(false); + expect( + retained.some((group) => group.groupId === groups[1].groupId), + ).toBe(true); + }); - const socket = FakeWebSocket.instances[0]; - socket.open(); - expect(socket.sent).toContainEqual( - JSON.stringify({ type: 'subscribe', channel: 'user_stats/28' }), + it('finds an older filled child without inventing its execution price', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const result = await built.provider.placeOrder(intent); + const [first] = venue.active.splice(0, 1); + const filled = { + ...first, + status: 'filled', + filledBaseAmount: first.initialBaseAmount, + remainingBaseAmount: '0', + }; + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => + cursor === 'older' + ? { code: 200, orders: [filled] } + : { + code: 200, + orders: [{ ...filled, orderIndex: 999, clientOrderIndex: 999 }], + nextCursor: 'older', + }, ); - expect(socket.sent).toContainEqual( - JSON.stringify({ - type: 'subscribe', - channel: 'account_all_positions/28', + const groups = await built.provider.reviewScaleOrderGroups(); + expect(result.success).toBe(true); + expect(groups).toStrictEqual([ + expect.objectContaining({ + childOrderIds: ['801'], + filledSize: '0.0005', }), + ]); + expect(groups[0]).not.toHaveProperty('averagePrice'); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledWith( + 28, + expect.any(String), + 100, + 'older', + 1, ); + }); - 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', + it('retains authoritative fills observed during exact group cancellation', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + const result = await built.provider.placeOrder(intent); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + const row = venue.history.at(-1); + if (row) { + row.status = 'filled'; + row.filledBaseAmount = row.initialBaseAmount; + row.remainingBaseAmount = '0'; + } + return { code: 200, txHash: '0xsent' }; }, - }); - expect(accountCallback).toHaveBeenCalledWith( + ); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(true); + expect(await built.provider.getScaleOrderGroups()).toStrictEqual([ expect.objectContaining({ - totalBalance: '11000', - spendableBalance: '6000', - marginUsed: '4000', - unrealizedPnl: '1000', + state: 'terminal', + childOrderIds: [], + filledSize: '0.001', + acceptedSize: '0.001', }), - ); + ]); + }); - 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', - }, + it('preserves the accepted first child and unknown second after response loss without replay', async () => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + scaleVenue(first); + const send = first.clientInstance.sendTx.getMockImplementation(); + let creates = 0; + first.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + creates += 1; + if (creates === 2) { + throw new Error('connection lost'); + } + await send?.(...args); + return { code: 200, txHash: '0xsent' }; }, + ); + const result = await first.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect(result.submittedSize).toBe('0.001'); + expect(result.acceptedSize).toBeUndefined(); + expect(result.childOrderIds).toStrictEqual(['800']); + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, }); - expect(positionsCallback).toHaveBeenCalledWith([ - expect.objectContaining({ symbol: 'BTC', size: '-0.5' }), + expect(await restarted.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ + state: 'unknown', + submittedSize: '0.001', + childOrderIds: ['800'], + }), ]); - unsubscribeAccount(); - unsubscribePositions(); - await provider.disconnect(); + expect(restarted.calls).toStrictEqual([]); + expect((await restarted.provider.placeOrder(intent)).success).toBe(false); + expect(restarted.calls).toStrictEqual([]); }); - it('applies position snapshots and deltas transactionally', async () => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeCtor, - registeredKey: 'a'.repeat(80), + it.each(['failed', 'expired'] as const)( + 'retires an unknown child only after fresh exact %s proof', + async (outcome) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.sendTx.mockRejectedValue( + new Error('lost response'), + ); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + const key = `lighterScaleOrders:testnet:${ACCOUNT.l1Address.toLowerCase()}:28`; + const raw = await infra.diskCache.getItem(key); + const [group] = parseLighterScaleGroups(raw); + const rung = group.rungs[0]; + const clock = + outcome === 'expired' + ? jest + .spyOn(Date, 'now') + .mockReturnValue(Number(rung.expiresAt) + 31_000) + : undefined; + if (outcome === 'failed') { + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: rung.txHash, + nonce: rung.nonce, + apiKeyIndex: 7, + accountIndex: 28, + status: 0, + }); + } + try { + const canceled = await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }); + expect(canceled.success).toBe(true); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(await built.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ + state: 'terminal', + acceptedSize: '0', + childOrderIds: [], + }), + ]); + } finally { + clock?.mockRestore(); + } + }, + ); + + it('keeps absent unexpired placement protected despite a consumed nonce', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], }); - clientInstance.getOrderBookDetails.mockResolvedValue({ + built.clientInstance.getInactiveOrders.mockResolvedValue({ code: 200, - orderBookDetails: [ - { - ...BTC_MARKET, - lastTradePrice: 100000, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - { - ...BTC_MARKET, - symbol: 'SOL', - marketId: 2, - lastTradePrice: 100, - minInitialMarginFraction: 400, - maintenanceMarginFraction: 240, - }, - ], + orders: [], }); - const callback = jest.fn(); - const unsubscribe = provider.subscribeToPositions({ callback }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - const socket = FakeWebSocket.instances[0]; - socket.open(); - const position = { - 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: '40000', - }; - socket.receive({ - type: 'subscribed/account_all_positions', - positions: { '1': position }, + built.clientInstance.sendTx.mockRejectedValue(new Error('lost response')); + const result = await built.provider.placeOrder(intent); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: 1000, }); - expect(callback).toHaveBeenLastCalledWith([ - expect.objectContaining({ symbol: 'BTC', size: '0.5' }), + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(false); + expect(await built.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ state: 'unknown' }), ]); - callback.mockClear(); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + }); - socket.receive({ - type: 'subscribed/account_all_positions', - positions: { - '2': { ...position, market_id: 2, symbol: 'SOL', position: '1' }, - '3': { - ...position, - market_id: 3, - symbol: 'ETH', - position: '0.2oops', - }, - }, + it('fails closed when the source account changes while signing a child', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const spy = jest.spyOn(built.bridge, 'execute'); + const execute = spy.getMockImplementation(); + spy.mockImplementation(async (call) => { + const signed = await execute?.(call); + if (call.function === '_signCreateOrder') { + built.getUserAddressMock.mockReturnValue( + '0x1111111111111111111111111111111111111111', + ); + } + if (!signed) { + throw new Error('Missing signer fixture'); + } + return signed; }); - expect(callback).not.toHaveBeenCalled(); + expect((await built.provider.placeOrder(intent)).success).toBe(false); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); - socket.receive({ - type: 'update/account_all_positions', - positions: { - '2': { ...position, market_id: 2, symbol: 'SOL', position: '1' }, + it('retains old-account ownership when the source switches during send', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + built.getUserAddressMock.mockReturnValue( + '0x1111111111111111111111111111111111111111', + ); + return { code: 200, txHash: '0xsent' }; }, - }); - expect(callback).toHaveBeenLastCalledWith([ - expect.objectContaining({ symbol: 'BTC', size: '0.5' }), - expect.objectContaining({ symbol: 'SOL', size: '1' }), + ); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + built.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + expect(await built.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ + state: 'unknown', + walletAddress: ACCOUNT.l1Address.toLowerCase(), + }), ]); + }); - const late = jest.fn(); - const unsubscribeLate = provider.subscribeToPositions({ callback: late }); - expect(late).toHaveBeenCalledWith([ - expect.objectContaining({ symbol: 'BTC', size: '0.5' }), - expect.objectContaining({ symbol: 'SOL', size: '1' }), + it('refuses cancellation signed after the source account changes', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + scaleVenue(built); + const result = await built.provider.placeOrder(intent); + const spy = jest.spyOn(built.bridge, 'execute'); + const execute = spy.getMockImplementation(); + spy.mockImplementation(async (call) => { + const signed = await execute?.(call); + if (call.function === '_signCancelOrder') { + built.getUserAddressMock.mockReturnValue( + '0x1111111111111111111111111111111111111111', + ); + } + if (!signed) { + throw new Error('Missing signer fixture'); + } + return signed; + }); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: String(result.orderId), + orderType: 'scale', + }) + ).success, + ).toBe(false); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + built.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + expect(await built.provider.getScaleOrderGroups()).toStrictEqual([ + expect.objectContaining({ childOrderIds: ['800', '801'] }), ]); - unsubscribeLate(); - unsubscribe(); - await provider.disconnect(); }); - it('keeps the previous orders snapshot when a new snapshot is malformed', async () => { - const { provider } = buildProvider({ - webSocketCtor: fakeCtor, - registeredKey: 'a'.repeat(80), - }); - const callback = jest.fn(); - const unsubscribe = provider.subscribeToOrders({ callback }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - const socket = FakeWebSocket.instances[0]; - socket.open(); - const validOrder = { - order_index: 555, - client_order_index: 1, - market_index: 1, - owner_account_index: 28, - initial_base_amount: '0.001', - remaining_base_amount: '0.001', - price: '90000', - is_ask: false, + it('reserves unrelated reduce-only orders against the entire remaining ladder', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = scaleVenue(built); + venue.active.push({ + orderIndex: 700, + clientOrderIndex: 1000, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.0995', + remainingBaseAmount: '0.0995', + price: '100000', + isAsk: true, type: 'limit', - time_in_force: 'good-till-time', - reduce_only: 0, + timeInForce: 'good-till-time', + reduceOnly: 1, status: 'open', - order_expiry: 0, - timestamp: 1700000000000, - }; - socket.receive({ - type: 'subscribed/account_all_orders', - channel: 'account_all_orders:28', - orders: { '1': [validOrder] }, + orderExpiry: 0, + timestamp: Date.now(), }); - expect(callback).toHaveBeenLastCalledWith([ - expect.objectContaining({ orderId: '555' }), - ]); - callback.mockClear(); - - socket.receive({ - type: 'subscribed/account_all_orders', - channel: 'account_all_orders:28', - orders: { - '1': [{ ...validOrder, status: 'venue-added-state' }], - }, + const result = await built.provider.placeOrder({ + ...intent, + isBuy: false, + reduceOnly: true, }); - expect(callback).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.error).toContain('aggregate reduce-only'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); - const late = jest.fn(); - const unsubscribeLate = provider.subscribeToOrders({ callback: late }); - expect(late).toHaveBeenCalledWith([ - expect.objectContaining({ orderId: '555' }), - ]); - unsubscribeLate(); - unsubscribe(); - await provider.disconnect(); + it('stops after an acknowledged child without authoritative acceptance', async () => { + jest.useFakeTimers(); + try { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + let finished = false; + const placing = built.provider.placeOrder(intent).then((result) => { + finished = true; + return result; + }); + await jest.advanceTimersByTimeAsync(9999); + expect(finished).toBe(false); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + const result = await placing; + expect(result.success).toBe(false); + expect(result.acceptedSize).toBeUndefined(); + expect(result.submittedSize).toBe('0.0005'); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + } finally { + jest.useRealTimers(); + } }); - it('drops malformed user_stats numbers without throwing or emitting a partial account state', async () => { - const infra = createMockInfrastructure(); - const { provider } = buildProvider({ - webSocketCtor: fakeCtor, - registeredKey: 'a'.repeat(80), - platformDependencies: infra, - }); - const accountCallback = jest.fn(); - const unsubscribe = provider.subscribeToAccount({ - callback: accountCallback, - }); - 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(); - accountCallback.mockClear(); - - expect(() => - socket.receive({ - type: 'update/user_stats', - channel: 'user_stats:28', - stats: { - collateral: '10000USD', - portfolio_value: '11000', - leverage: '2', - available_balance: '6000', - margin_usage: '40', - buying_power: '0', + it('rechecks margin after signing and refuses dispatch if funds disappeared', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getAccountByIndex.mockImplementation(async () => ({ + code: 200, + accounts: [ + { + ...ACCOUNT, + availableBalance: built.calls.some( + (call) => call.function === '_signCreateOrder', + ) + ? '0' + : ACCOUNT.availableBalance, }, - }), - ).not.toThrow(); - expect(accountCallback).not.toHaveBeenCalled(); - expect(infra.debugLogger.log).toHaveBeenCalledWith( - '[LighterProvider] dropped malformed WebSocket frame', - expect.objectContaining({ - error: expect.stringContaining('Invalid Lighter venue data'), - }), - ); - - unsubscribe(); - await provider.disconnect(); - }); - - it('does not emit authoritative empty state when account-channel discovery fails', async () => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeCtor, - configuredAccountIndex: null, - }); - clientInstance.getAccountsByL1Address.mockRejectedValue( - new Error('Invalid Lighter venue data: malformed account discovery'), - ); - const accountCallback = jest.fn(); - const positionsCallback = jest.fn(); - const ordersCallback = jest.fn(); - const unsubscribeAccount = provider.subscribeToAccount({ - callback: accountCallback, - }); - const unsubscribePositions = provider.subscribeToPositions({ - callback: positionsCallback, - }); - const unsubscribeOrders = provider.subscribeToOrders({ - callback: ordersCallback, - }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - - expect(accountCallback).not.toHaveBeenCalled(); - expect(positionsCallback).not.toHaveBeenCalled(); - expect(ordersCallback).not.toHaveBeenCalled(); - unsubscribeAccount(); - unsubscribePositions(); - unsubscribeOrders(); - await provider.disconnect(); + ], + })); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect(result.error).toContain('remaining margin'); + expect(result.submittedSize).toBe('0'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('emits authoritative empty state when the selected wallet has no Lighter account', async () => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeCtor, - configuredAccountIndex: null, - }); - clientInstance.getAccountsByL1Address.mockRejectedValue( - new LighterApiError('account not found', 21100), - ); - const accountCallback = jest.fn(); - const positionsCallback = jest.fn(); - const ordersCallback = jest.fn(); - const unsubscribeAccount = provider.subscribeToAccount({ - callback: accountCallback, - }); - const unsubscribePositions = provider.subscribeToPositions({ - callback: positionsCallback, - }); - const unsubscribeOrders = provider.subscribeToOrders({ - callback: ordersCallback, - }); - - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); + it.each([ + { scaleMinPrice: '90000junk' }, + { scaleMaxPrice: '90000.01', scaleNumOrders: 3 }, + { size: '0.00001' }, + { scaleSkew: 0 }, + { price: '90000' }, + { takeProfitPrice: '120000' }, + { timeInForce: 'IOC' as const }, + { twapDuration: 5 }, + { chaseIntervalMs: 1000 }, + ])( + 'refuses invalid or unsupported scale intent before any bridge call: %j', + async (override) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const params = { ...intent, ...override }; + expect((await built.provider.validateOrder(params)).isValid).toBe( + false, + ); + expect((await built.provider.placeOrder(params)).success).toBe(false); + expect(built.calls).toStrictEqual([]); + }, + ); + }); - expect(accountCallback).toHaveBeenCalledWith( - expect.objectContaining({ - totalBalance: '0', - spendableBalance: '0', - providerId: 'lighter', - }), - ); - expect(positionsCallback).toHaveBeenCalledWith([]); - expect(ordersCallback).toHaveBeenCalledWith([]); - unsubscribeAccount(); - unsubscribePositions(); - unsubscribeOrders(); - await provider.disconnect(); - }); + beforeEach(() => { + jest.clearAllMocks(); + }); - it('also settles account subscribers when discovery succeeds with no accounts', async () => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeCtor, - configuredAccountIndex: null, - }); - clientInstance.getAccountsByL1Address.mockResolvedValue({ + describe('native attached placement', () => { + const attachedIntent: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + takeProfitPrice: '110000', + stopLossPrice: '80000', + }; + const durableInfrastructure = (): { + disk: Map; + infrastructure: ReturnType; + } => { + const disk = new Map(); + const infrastructure = createMockInfrastructure(); + jest + .mocked(infrastructure.diskCache) + .getItem.mockImplementation(async (key) => disk.get(key) ?? null); + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + disk.set(key, value); + }); + jest + .mocked(infrastructure.diskCache) + .removeItem.mockImplementation(async (key) => { + disk.delete(key); + }); + return { disk, infrastructure }; + }; + const attachedVenue = ( + bundle: BuiltProvider, + ): { active: LighterApiOrder[]; inactive: LighterApiOrder[] } => { + const active: LighterApiOrder[] = []; + const inactive: LighterApiOrder[] = []; + bundle.clientInstance.getActiveOrders.mockImplementation(async () => ({ code: 200, - l1Address: ACCOUNT.l1Address, - subAccounts: [], - }); - const positionsCallback = jest.fn(); - const ordersCallback = jest.fn(); - const unsubscribePositions = provider.subscribeToPositions({ - callback: positionsCallback, - }); - const unsubscribeOrders = provider.subscribeToOrders({ - callback: ordersCallback, - }); - - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - - expect(positionsCallback).toHaveBeenCalledWith([]); - expect(ordersCallback).toHaveBeenCalledWith([]); - unsubscribePositions(); - unsubscribeOrders(); - await provider.disconnect(); - }); - - it('does not clear orders when authenticated channel setup fails', async () => { - const { provider, bridge } = buildProvider({ - webSocketCtor: fakeCtor, - registeredKey: 'a'.repeat(80), - }); - const realExecute = (bridge.execute as jest.Mock) - .getMockImplementation() - ?.bind(bridge); - (bridge.execute as jest.Mock).mockImplementation( - async (call: LighterWasmCall) => { - if (call.function === '_createAuthToken') { - throw new Error( - 'Invalid Lighter venue data: malformed auth-token response', - ); + orders: [...active], + })); + bundle.clientInstance.getInactiveOrders.mockImplementation(async () => ({ + code: 200, + orders: [...inactive], + })); + bundle.clientInstance.sendTx.mockImplementation(async (kind: number) => { + if (kind === 28) { + const call = bundle.calls + .filter((entry) => entry.function === '_signCreateGroupedOrders') + .at(-1); + if (call?.function !== '_signCreateGroupedOrders') { + throw new Error('Missing grouped signature'); } - return await realExecute?.(call); - }, - ); - const ordersCallback = jest.fn(); - const unsubscribe = provider.subscribeToOrders({ - callback: ordersCallback, - }); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - await new Promise((resolveTick) => setImmediate(resolveTick)); - - expect(ordersCallback).not.toHaveBeenCalledWith([]); - expect(ordersCallback).not.toHaveBeenCalled(); - unsubscribe(); - 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(), + for (let index = 0; index < call.params[2]; index += 1) { + const offset = 3 + index * 10; + active.push({ + orderIndex: 500 + index, + clientOrderIndex: Number(call.params[offset + 1]), + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: String( + Number(call.params[offset + 2]) / 100000, + ), + remainingBaseAmount: String( + Number(call.params[offset + 2]) / 100000, + ), + price: String(Number(call.params[offset + 3]) / 10), + isAsk: call.params[offset + 4] === 1, + type: [ + 'limit', + 'market', + 'stop-loss', + 'stop-loss-limit', + 'take-profit', + 'take-profit-limit', + ][Number(call.params[offset + 5])], + timeInForce: + call.params[offset + 6] === 1 + ? 'good-till-time' + : 'immediate-or-cancel', + reduceOnly: Number(call.params[offset + 7]), + triggerPrice: String(Number(call.params[offset + 8]) / 10), + status: index === 0 ? 'open' : 'pending', + orderExpiry: Date.now() + 1000000, + timestamp: Math.floor(Date.now() / 1000), + ...(index === 0 + ? { toTriggerOrderId0: '501', toTriggerOrderId1: '502' } + : { parentOrderIndex: 500 }), + }); + } + } else if (kind === 15) { + const call = bundle.calls + .filter((entry) => entry.function === '_signCancelOrder') + .at(-1); + const index = active.findIndex( + (row) => String(row.orderIndex) === call?.params[2], + ); + if (index >= 0) { + const [row] = active.splice(index, 1); + inactive.push({ + ...row, + status: 'canceled', + remainingBaseAmount: '0', + }); + } + } + return { code: 200, txHash: 'aabbcc', predictedExecutionTimeMs: 100 }; }); - FakeWebSocket.instances[0].open(); - expect(transitions).toStrictEqual([ - 'disconnected', - 'connecting', - 'connected', - ]); - expect(provider.getWebSocketConnectionState()).toBe('connected'); + return { active, inactive }; + }; - 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' }), + const storedAttached = ( + disk: Map, + ): { key: string; groups: LighterAttachedGroup[] } => { + const entry = [...disk.entries()].find(([key]) => + key.startsWith('lighterAttachedOrders:'), ); + if (!entry) { + throw new Error('Missing attached storage'); + } + return { + key: entry[0], + groups: JSON.parse(entry[1]) as LighterAttachedGroup[], + }; + }; - 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, + it.each([false, true])( + 'preserves exact failure reviewed before ledger reconciliation with retirement failure=%s', + async (failRetirement) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - expect(provider.getWebSocketConnectionState()).toBe('disconnected'); - const callback = jest.fn(); - const unsubscribe = provider.subscribeToPrices({ - symbols: [], - callback, + built.clientInstance.sendTx.mockRejectedValueOnce( + new Error('lost response'), + ); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + const failure = { + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 0, + }; + built.clientInstance.getTx.mockResolvedValue(failure); + expect( + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('canceled'); + let failed = false; + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + if ( + failRetirement && + !failed && + key === 'lighterNonceLedger:testnet:28:7' && + value.includes('"entries":[]') + ) { + failed = true; + throw new Error('ledger write failed'); + } + disk.set(key, value); + }); + const restarted = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + apiKeyIndex: 8, }); - expect(provider.getWebSocketConnectionState()).toBe('connected'); - await Promise.resolve(); - await Promise.resolve(); - expect(callback).toHaveBeenCalledWith([ - expect.objectContaining({ symbol: 'BTC', price: '100000' }), + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [7, 8].map((apiKeyIndex) => ({ + accountIndex: 28, + apiKeyIndex, + nonce: 1, + publicKey: '9c'.repeat(40), + })), + }); + restarted.clientInstance.getTx.mockResolvedValue(failure); + let retirementError: unknown; + if (failRetirement) { + retirementError = await restarted.provider + .reconcileRecoveredDispatches() + .then( + () => undefined, + (error: unknown) => error, + ); + await restarted.provider.reviewAttachedOrderGroups(); + } + expect(retirementError).toStrictEqual( + failRetirement ? new Error('ledger write failed') : undefined, + ); + expect(storedAttached(disk).groups[0].nonAcceptance).toBe('failed'); + const recovered = + await restarted.provider.reconcileRecoveredDispatches(); + expect(recovered).toStrictEqual([ + expect.objectContaining({ outcome: 'failed' }), ]); + expect(recovered[0].acknowledgeable).not.toBe(false); + await restarted.provider.acknowledgeRecoveredDispatch( + recovered[0].recoveryId, + ); + expect( + ( + await restarted.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }) + ).success, + ).toBe(true); + expect( + restarted.calls.filter( + (call) => + call.function === '_signCreateGroupedOrders' || + call.function === '_signCancelOrder', + ), + ).toHaveLength(0); + }, + ); - await jest.advanceTimersByTimeAsync(10_500); + it.each([ + ['live', false], + ['older-terminal', false], + ['older-terminal', true], + ] as const)( + 'settles fresh exact %s legs across restart and migration with ledger failure=%s', + async (state, failRetirement) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, + ); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); expect( - clientInstance.getOrderBookDetails.mock.calls.length, - ).toBeGreaterThanOrEqual(3); + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('accepted'); + const restarted = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + apiKeyIndex: 8, + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [7, 8].map((apiKeyIndex) => ({ + accountIndex: 28, + apiKeyIndex, + nonce: 1, + publicKey: '9c'.repeat(40), + })), + }); + const replayVenue = attachedVenue(restarted); + if (state === 'live') { + replayVenue.active.push(...venue.active); + } else { + const terminal = venue.active.map((row) => ({ + ...row, + status: 'canceled', + remainingBaseAmount: '0', + })); + restarted.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => + cursor === 'older' + ? { code: 200, orders: terminal } + : { + code: 200, + orders: [ + { + ...terminal[0], + orderIndex: 999, + clientOrderIndex: 999, + }, + ], + nextCursor: 'older', + }, + ); + } + restarted.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: Number(group.nonce) + 1, + }); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.expiresAt) + 31_000); + try { + let retirementError: unknown; + if (failRetirement) { + let failed = false; + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + if ( + !failed && + key === 'lighterNonceLedger:testnet:28:7' && + value.includes('fresh-exact-attached-legs') + ) { + failed = true; + throw new Error('acceptance ledger write failed'); + } + disk.set(key, value); + }); + retirementError = await restarted.provider + .reviewAttachedOrderGroups() + .then( + () => undefined, + (error: unknown) => error, + ); + } + expect(retirementError).toStrictEqual( + failRetirement + ? new Error('acceptance ledger write failed') + : undefined, + ); + expect(storedAttached(disk).groups[0].submission).toBe( + failRetirement ? 'completed' : 'accepted', + ); + expect( + storedAttached(disk).groups[0].acceptanceReviewedAt, + ).toBeDefined(); + await restarted.provider.reviewAttachedOrderGroups(); + const recovered = + await restarted.provider.reconcileRecoveredDispatches(); + expect(recovered).toStrictEqual([ + expect.objectContaining({ outcome: 'succeeded' }), + ]); + expect(recovered[0].acknowledgeable).not.toBe(false); + await restarted.provider.acknowledgeRecoveredDispatch( + recovered[0].recoveryId, + ); + expect( + ( + await restarted.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(true); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(0); + expect( + ( + await restarted.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }) + ).success, + ).toBe(true); + } finally { + clock.mockRestore(); + } + }, + ); - unsubscribe(); - expect(provider.getWebSocketConnectionState()).toBe('disconnected'); - const callsAfter = clientInstance.getOrderBookDetails.mock.calls.length; - await jest.advanceTimersByTimeAsync(20_000); - expect(clientInstance.getOrderBookDetails.mock.calls).toHaveLength( - callsAfter, + it.each(['nonce', 'hash', 'expiry', 'kind', 'owner'] as const)( + 'refuses fresh complete acceptance against a same-intent ledger with different %s', + async (mismatch) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, ); - await provider.disconnect(); - expect(provider.getWebSocketConnectionState()).toBe('disconnected'); - } finally { - jest.useRealTimers(); - } - }); - }); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.preparedAt) + 1000); + try { + // Persist the complete observation without consuming the pending entry. + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce, + }); + await built.provider.reviewAttachedOrderGroups(); + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ledger = JSON.parse(String(disk.get(ledgerKey))) as { + entries: { + nonce: number; + txHash: string; + expiresAt: number; + kind: number; + owner: string | null; + }[]; + }; + if (mismatch === 'nonce') { + ledger.entries[0].nonce += 1; + } + if (mismatch === 'hash') { + ledger.entries[0].txHash = 'abcd'; + } + if (mismatch === 'expiry') { + ledger.entries[0].expiresAt += 1; + } + if (mismatch === 'kind') { + ledger.entries[0].kind = 14; + } + if (mismatch === 'owner') { + ledger.entries[0].owner = 'different-owner'; + } + disk.set(ledgerKey, JSON.stringify(ledger)); + const { key } = storedAttached(disk); + const journalBefore = disk.get(key); + const ledgerBefore = disk.get(ledgerKey); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: Number(group.nonce) + 10, + }); + built.clientInstance.getTx.mockClear(); + built.clientInstance.getActiveOrders.mockClear(); + const reviewed = await built.provider.reviewAttachedOrderGroups(); + expect(reviewed[0].submission).toBe('accepted'); + expect(venue.active).toHaveLength(3); + expect(built.clientInstance.getActiveOrders).toHaveBeenCalledTimes(1); + expect(built.clientInstance.getTx).toHaveBeenCalledWith(group.txHash); + expect(built.clientInstance.getNextNonce).toHaveBeenCalledWith(28, 7); + expect(disk.get(ledgerKey)).toBe(ledgerBefore); + expect(disk.get(key)).toBe(journalBefore); + const pending = await built.provider.getRecoveredDispatches(); + expect(pending).toStrictEqual([ + expect.objectContaining({ + outcome: 'unknown', + acknowledgeable: false, + }), + ]); + await expect( + built.provider.acknowledgeRecoveredDispatch(pending[0].recoveryId), + ).rejects.toThrow('cannot be acknowledged'); + } finally { + clock.mockRestore(); + } + }, + ); - describe('session binding', () => { - it('re-arms one signer reset listener after wallet deselection and reselection', async () => { - const { - provider, - calls, - fireReset, - getUserAddressMock, - resetListenerCount, - } = buildProvider({ registeredKey: '9c'.repeat(40) }); - expect((await provider.isReadyToTrade()).ready).toBe(true); - expect( - calls.filter((call) => call.function === '_createClient'), - ).toHaveLength(1); + it.each(['bounded-history', 'recorded-id', 'partial-leg'] as const)( + 'refuses exact failed retirement with %s', + async (evidence) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, + ); + await built.provider.placeOrder(attachedIntent); + const { key, groups } = storedAttached(disk); + const group = groups[0]; + const [template] = venue.active.splice(0); + if (evidence === 'recorded-id') { + group.venueIds[0] = '500'; + } + if (evidence === 'partial-leg') { + venue.active.push(template); + group.venueIds[0] = '500'; + } + disk.set(key, JSON.stringify(groups)); + if (evidence === 'bounded-history') { + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => { + const page = Number(cursor ?? 0); + return { + code: 200, + orders: Array.from({ length: 100 }, (_value, index) => ({ + ...template, + orderIndex: 10000 + page * 100 + index, + clientOrderIndex: 10000 + page * 100 + index, + })), + nextCursor: String(page + 1), + }; + }, + ); + } + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 0, + }); + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const before = { ledger: disk.get(ledgerKey), journal: disk.get(key) }; + const reviewed = await built.provider.reviewAttachedOrderGroups(); + expect(reviewed[0].submission).toBe('unknown'); + expect(reviewed[0].historyStatus).toBe( + evidence === 'bounded-history' ? 'bounded' : 'complete', + ); + expect(built.clientInstance.getTx).toHaveBeenCalledWith(group.txHash); + expect(disk.get(key)).toBe(before.journal); + expect(disk.get(ledgerKey)).toBe(before.ledger); + const pending = await built.provider.getRecoveredDispatches(); + expect(pending).toStrictEqual([ + expect.objectContaining({ + outcome: 'unknown', + acknowledgeable: false, + }), + ]); + await expect( + built.provider.acknowledgeRecoveredDispatch(pending[0].recoveryId), + ).rejects.toThrow('cannot be acknowledged'); + }, + ); - getUserAddressMock.mockImplementation(() => { - throw new Error('no selected wallet account'); + it('keeps a local terminal group unchanged while reviewing another group', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - await provider.getAccountState(); - expect(resetListenerCount()).toBe(0); - - getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); - await provider.getAccountState(); - expect(resetListenerCount()).toBe(1); - fireReset(); - - expect((await provider.isReadyToTrade()).ready).toBe(true); + const venue = attachedVenue(built); + const first = await built.provider.placeOrder(attachedIntent); + const firstId = first.attachedOrderGroup?.groupId; + if (!firstId) { + throw new Error('Missing first group'); + } expect( - calls.filter((call) => call.function === '_createClient'), - ).toHaveLength(2); - await provider.disconnect(); - expect(resetListenerCount()).toBe(0); + (await built.provider.cancelOrder({ symbol: 'BTC', orderId: firstId })) + .success, + ).toBe(true); + const terminal = storedAttached(disk).groups[0]; + venue.inactive.length = 0; + const second = await built.provider.placeOrder(attachedIntent); + expect(second.success).toBe(true); + built.clientInstance.getActiveOrders.mockClear(); + const groups = await built.provider.reviewAttachedOrderGroups(); + expect(groups.map((group) => group.submission)).toStrictEqual([ + 'canceled', + 'accepted', + ]); + expect(storedAttached(disk).groups[0]).toStrictEqual(terminal); + expect(built.clientInstance.getActiveOrders).toHaveBeenCalledTimes(1); + expect(built.clientInstance.getActiveOrders).toHaveBeenCalledWith( + 28, + expect.any(String), + 1, + ); }); - it('replaces the signer reset listener on account rebind and removes it on disconnect', async () => { - const { - provider, - getUserAddressMock, - resetListenerCount, - resetUnsubscribe, - } = buildProvider(); - expect(resetListenerCount()).toBe(1); - await provider.getAccountState(); - getUserAddressMock.mockReturnValue( - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + it('clears retained nonacceptance when only one exact leg appears', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, ); - await provider.getAccountState().catch(() => undefined); - expect(resetUnsubscribe).toHaveBeenCalledTimes(1); - expect(resetListenerCount()).toBe(1); - await provider.disconnect(); - expect(resetUnsubscribe).toHaveBeenCalledTimes(2); - expect(resetListenerCount()).toBe(0); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + const [parent] = venue.active.splice(0); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.expiresAt) + 31_000); + try { + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce, + }); + await built.provider.reconcileRecoveredDispatches(); + expect(storedAttached(disk).groups[0].nonAcceptance).toBe('expired'); + venue.active.push(parent); + const [reviewed] = await built.provider.reviewAttachedOrderGroups(); + expect(reviewed.submission).toBe('accepted'); + expect( + reviewed.orders?.slice(1).map((row) => row.status), + ).toStrictEqual(['unknown', 'unknown']); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + expect(storedAttached(disk).groups[0].venueIds).toStrictEqual([ + '500', + null, + null, + ]); + } finally { + clock.mockRestore(); + } }); - 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({ + it.each([ + 'partial', + 'missing', + 'mismatched-side', + 'mismatched-hash', + 'nonce-not-advanced', + ] as const)( + 'does not settle acceptance from %s evidence', + async (control) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, + ); + await built.provider.placeOrder(attachedIntent); + await built.provider.reviewAttachedOrderGroups(); + const { + groups: [group], + } = storedAttached(disk); + if (control === 'partial') { + venue.active.pop(); + } + if (control === 'missing') { + venue.active.splice(0); + } + if (control === 'mismatched-side') { + venue.active[0].isAsk = !venue.active[0].isAsk; + } + if (control === 'mismatched-hash') { + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: 'beef', + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 2, + }); + } + built.clientInstance.getNextNonce.mockResolvedValue({ code: 200, - l1Address: '0xbbbb', - subAccounts: [accountB], + nonce: + Number(group.nonce) + (control === 'nonce-not-advanced' ? 0 : 1), }); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.expiresAt) + 31_000); + try { + const review = await built.provider.reviewAttachedOrderGroups().then( + (groups) => groups, + (error: unknown) => error, + ); + expect(Array.isArray(review)).toBe(control !== 'mismatched-side'); + expect(review instanceof Error ? review.message : undefined).toBe( + control === 'mismatched-side' + ? 'Lighter attached-order identity does not match signed intent' + : undefined, + ); + const pending = await built.provider.reconcileRecoveredDispatches(); + expect(pending).toStrictEqual([ + expect.objectContaining({ + outcome: 'unknown', + acknowledgeable: false, + }), + ]); + await expect( + built.provider.acknowledgeRecoveredDispatch(pending[0].recoveryId), + ).rejects.toThrow('cannot be acknowledged'); + expect( + built.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(1); + } finally { + clock.mockRestore(); + } + }, + ); - // 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], + it('stops attached history at preparation time before requesting an older page', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - await readUnderA; + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const [template] = venue.active.splice(0); + const old = { + ...template, + clientOrderIndex: 999, + orderIndex: 999, + timestamp: Math.floor(Date.now() / 1000) - 3600, + }; + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => { + if (cursor) { + throw new Error('read beyond preparation cutoff'); + } + return { code: 200, orders: [old], nextCursor: 'older' }; + }, + ); + const groups = await built.provider.reviewAttachedOrderGroups(); + expect(groups).toHaveLength(1); + expect( + groups[0].orders?.every((order) => order.status === 'unknown'), + ).toBe(true); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(1); + expect(storedAttached(disk).groups[0].submission).toBe('accepted'); + }); - const accountReads = clientInstance.getAccountByIndex.mock.calls.map( - (call) => call[0], + it('returns bounded unknown for a missing group when the attached history budget is exhausted', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const [template] = venue.active.splice(0); + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => { + const page = Number(cursor ?? 0); + return { + code: 200, + orders: Array.from({ length: 100 }, (_value, index) => ({ + ...template, + orderIndex: 10000 + page * 100 + index, + clientOrderIndex: 10000 + page * 100 + index, + })), + nextCursor: String(page + 1), + }; + }, ); - expect(accountReads).not.toContain(28); - expect(accountReads).toContain(900); + const groups = await built.provider.reviewAttachedOrderGroups(); + expect(groups).toHaveLength(1); + expect( + groups[0].orders?.every((order) => order.status === 'unknown'), + ).toBe(true); + expect( + built.clientInstance.getInactiveOrders.mock.calls.length, + ).toBeLessThanOrEqual(100); }); - it('rebuilds stream channels for the NEW account after a switch', async () => { - const { provider, clientInstance, getUserAddressMock } = buildProvider({ - webSocketCtor: fakeStreamCtor, - configuredAccountIndex: null, + it('abandons an expired never-landed group without walking older unrelated history', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + 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], - }, - ), + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + built.clientInstance.sendTx.mockImplementation( + async (...args: unknown[]) => { + await send?.(...args); + throw new Error('lost response'); + }, ); - StreamFakeWebSocket.instances = []; - const accountCallback = jest.fn(); - const positionsCallback = jest.fn(); - const ordersCallback = jest.fn(); - const unsubscribePrices = provider.subscribeToPrices({ - symbols: [], - callback: jest.fn(), - }); - const unsubscribeAccount = provider.subscribeToAccount({ - callback: accountCallback, - }); - const unsubscribePositions = provider.subscribeToPositions({ - callback: positionsCallback, - }); - const unsubscribeOrders = provider.subscribeToOrders({ - callback: ordersCallback, - }); - 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' }), + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + const [template] = venue.active.splice(0); + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => { + if (cursor) { + throw new Error('older than preparation requested'); + } + return { + code: 200, + orders: Array.from({ length: 100 }, (_value, index) => ({ + ...template, + orderIndex: 10000 + index, + clientOrderIndex: 10000 + index, + timestamp: Math.floor(Number(group.preparedAt) / 1000) - 3600, + })), + nextCursor: 'remaining-10000-rows', + }; + }, ); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.expiresAt) + 31_000); + try { + await built.provider.reconcileRecoveredDispatches(); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(true); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(1); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + } finally { + clock.mockRestore(); + } + }); - // Wallet switches accounts; any session-bound call triggers rebind. - accountCallback.mockClear(); - positionsCallback.mockClear(); - ordersCallback.mockClear(); - getUserAddressMock.mockReturnValue('0xbbbb'); - await provider.getAccountState(); - expect(accountCallback).toHaveBeenCalledWith( - expect.objectContaining({ - totalBalance: '0', - spendableBalance: '0', - providerId: 'lighter', - }), - ); - expect(positionsCallback).toHaveBeenCalledWith([]); - expect(ordersCallback).toHaveBeenCalledWith([]); - 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' }), + it('keeps another exact group reviewable when one group reaches the history budget', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const firstRows = venue.active.splice(0); + const second = await built.provider.placeOrder(attachedIntent); + expect(second.success).toBe(true); + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => { + const page = Number(cursor ?? 0); + return { + code: 200, + orders: Array.from({ length: 100 }, (_value, index) => ({ + ...firstRows[0], + orderIndex: 10000 + page * 100 + index, + clientOrderIndex: 10000 + page * 100 + index, + })), + nextCursor: String(page + 1), + }; + }, ); + const groups = await built.provider.reviewAttachedOrderGroups(); + expect(groups).toHaveLength(2); + expect(groups[0].historyStatus).toBe('bounded'); expect( - replacement.sent.some((frame) => frame.includes('user_stats/28')), - ).toBe(false); - - unsubscribePrices(); - unsubscribeAccount(); - unsubscribePositions(); - unsubscribeOrders(); + groups[0].orders?.every((order) => order.status === 'unknown'), + ).toBe(true); + expect(groups[1].historyStatus).toBe('complete'); + expect( + groups[1].orders?.every((order) => order.orderId !== undefined), + ).toBe(true); }); - it('cancels a queued write when the wallet switches accounts first', async () => { - const { provider, clientInstance, getUserAddressMock } = buildProvider({ - configuredAccountIndex: null, + it('clears retained non-acceptance on executed cancellation evidence without inventing missing legs', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - // 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; - }), + built.clientInstance.sendTx.mockRejectedValueOnce( + new Error('lost response'), ); - 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'); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Number(group.expiresAt) + 31_000); + try { + await built.provider.reconcileRecoveredDispatches(); + expect(storedAttached(disk).groups[0].nonAcceptance).toBe('expired'); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 2, + }); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(false); + const saved = storedAttached(disk).groups[0]; + expect(saved.submission).toBe('accepted'); + expect(saved.nonAcceptance).toBeUndefined(); + expect(saved.venueIds).toStrictEqual([null, null, null]); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + } finally { + clock.mockRestore(); + } }); - }); - 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, + it('quarantines pre-expiry nonce advance until later exact execution is reviewable', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + built.clientInstance.sendTx.mockRejectedValue(new Error('lost response')); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + if (group.nonce === null || group.expiresAt === null) { + throw new Error('Missing identity'); + } + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt - 1); + try { + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce + 1, + }); + const pending = await built.provider.reconcileRecoveredDispatches(); + expect(pending).toHaveLength(1); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(false); + expect((await built.provider.placeOrder(attachedIntent)).success).toBe( + false, + ); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 2, + }); + expect( + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('accepted'); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + } finally { + clock.mockRestore(); + } + }); + + it.each(['executed', 'live-legs'] as const)( + 'keeps review usable when %s contradicts a retained non-acceptance proof', + async (evidence) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, 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; + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + if (!send) { + throw new Error('Missing venue'); + } + built.clientInstance.sendTx.mockImplementation( + async (kind: number, txInfo: string) => { + await send(kind, txInfo); + throw new Error('lost response'); + }, + ); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + if (group.nonce === null || group.expiresAt === null) { + throw new Error('Missing identity'); + } + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt + 700000); + try { + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce, + }); + await built.provider.reconcileRecoveredDispatches(); + expect(storedAttached(disk).groups[0].nonAcceptance).toBe('expired'); + if (evidence === 'executed') { + venue.active.splice(0); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 2, }); } - 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); + expect( + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('accepted'); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + } finally { + clock.mockRestore(); + } + }, + ); - // 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[1]).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, + it.each(['review', 'cancel'] as const)( + 'finds exact terminal attached legs on an older page during %s', + async (operation) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, 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', + const venue = attachedVenue(built); + const placed = await built.provider.placeOrder(attachedIntent); + const terminal = venue.active + .splice(0) + .map((row) => ({ ...row, status: 'canceled' })); + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => ({ + code: 200, + orders: + cursor === 'older' + ? terminal + : [{ ...terminal[0], orderIndex: 999, clientOrderIndex: 999 }], + ...(cursor === undefined ? { nextCursor: 'older' } : {}), + }), + ); + if (operation === 'review') { + await built.provider.reviewAttachedOrderGroups(); + } else { + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: placed.attachedOrderGroup?.groupId ?? '', + }); + } + expect(['completed', 'canceled']).toContain( + storedAttached(disk).groups[0].submission, + ); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledWith( + 28, + expect.any(String), + 100, + 'older', + 1, + ); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + }, + ); + + it('abandons retained exact failure after expiry when transaction history ages out', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - 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; + built.clientInstance.sendTx.mockRejectedValue(new Error('lost response')); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + if (group.expiresAt === null) { + throw new Error('Missing expiry'); + } + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 0, }); - 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); + await built.provider.reconcileRecoveredDispatches(); + expect(storedAttached(disk).groups[0].nonAcceptance).toBe('failed'); + built.clientInstance.getTx.mockResolvedValue(null); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt + 700000); + try { + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(true); + expect(storedAttached(disk).groups[0].submission).toBe('canceled'); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + } finally { + clock.mockRestore(); + } }); - 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, + it('retains ledger ownership after a live leg was recorded even when later history is absent', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - // 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': { + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + if (!send) { + throw new Error('Missing venue'); + } + built.clientInstance.sendTx.mockImplementationOnce( + async (kind: number, txInfo: string) => { + await send(kind, txInfo); + throw new Error('lost response'); + }, + ); + await built.provider.placeOrder(attachedIntent); + built.clientInstance.getTx.mockResolvedValue(null); + await built.provider.reviewAttachedOrderGroups(); + const { + groups: [group], + } = storedAttached(disk); + expect(group.venueIds).toStrictEqual(['500', '501', '502']); + if (group.nonce === null || group.expiresAt === null) { + throw new Error('Missing identity'); + } + venue.active.splice(0); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt + 700000); + try { + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce + 1, + }); + await built.provider.reviewAttachedOrderGroups(); + const pending = await built.provider.reconcileRecoveredDispatches(); + expect(pending).toHaveLength(1); + expect(pending[0].acknowledgeable).toBe(false); + expect( + ( + await built.provider.cancelOrder({ symbol: 'BTC', - market_id: 1, - index_price: '1', - mark_price: '1', - mid_price: '1', - last_trade_price: '1', - }, - }, - }), - }); - expect(callback).not.toHaveBeenCalled(); - unsubscribe(); + orderId: group.groupId, + }) + ).success, + ).toBe(false); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + expect(storedAttached(disk).groups[0].submission).toBe('accepted'); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + } finally { + clock.mockRestore(); + } }); - 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], - }, - ), + it('cancels fresh live legs instead of blindly abandoning an expired non-acceptance proof', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + const send = built.clientInstance.sendTx.getMockImplementation(); + if (!send) { + throw new Error('Missing venue'); + } + built.clientInstance.sendTx.mockImplementationOnce( + async (kind: number, txInfo: string) => { + await send(kind, txInfo); + throw new Error('lost response'); + }, ); - 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'); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + if (group.nonce === null || group.expiresAt === null) { + throw new Error('Missing identity'); + } + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt + 700000); + try { + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce + 1, + }); + await built.provider.reconcileRecoveredDispatches(); + expect(storedAttached(disk).groups[0].nonAcceptance).toBe( + 'nonce-consumed', + ); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(true); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(3); + expect(venue.active).toStrictEqual([]); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + } finally { + clock.mockRestore(); + } + }); + + it.each(['account', 'slot', 'nonce', 'hash', 'absent'] as const)( + 'retains accepted no-leg groups with %s failed-lookup mismatch or absence', + async (identity) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + built.clientInstance.getTx.mockResolvedValue( + identity === 'absent' + ? null + : { + code: 200, + hash: identity === 'hash' ? 'deadbeef' : group.txHash, + accountIndex: identity === 'account' ? 29 : 28, + apiKeyIndex: identity === 'slot' ? 19 : 7, + nonce: identity === 'nonce' ? 999 : group.nonce, + status: 0, + }, + ); + expect( + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('accepted'); + expect(storedAttached(disk).groups[0].submission).toBe('accepted'); + }, + ); + + it.each([ + 'repeated-cursor', + 'empty-advance', + 'missing', + 'wrong-account', + ] as const)( + 'keeps attached ownership protected on older history %s', + async (failure) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const terminal = venue.active + .splice(0) + .map((row) => ({ ...row, status: 'canceled' })); + const unrelated = { + ...terminal[0], + orderIndex: 999, + clientOrderIndex: 999, + }; + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, cursor) => { + let orders: LighterApiOrder[] = [unrelated]; + if (cursor !== undefined && failure === 'empty-advance') { + orders = []; + } + if (cursor !== undefined && failure === 'wrong-account') { + orders = terminal.map((row) => ({ + ...row, + ownerAccountIndex: 29, + })); + } + return { + code: 200, + orders, + ...(cursor === undefined || + failure === 'repeated-cursor' || + failure === 'empty-advance' + ? { nextCursor: 'older' } + : {}), + }; + }, + ); + let error: unknown; + try { + await built.provider.reviewAttachedOrderGroups(); + } catch (caught) { + error = caught; + } + expect(error instanceof Error).toBe( + failure === 'repeated-cursor' || failure === 'empty-advance', + ); + expect(storedAttached(disk).groups[0].submission).toBe('accepted'); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + }, + ); + + it('reclaims an accepted attached group whose exact transaction later failed without creating legs', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 0, + }); expect( - calls.filter((call) => call.function === '_signCreateOrder'), - ).toHaveLength(0); + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('canceled'); + expect(storedAttached(disk).groups[0].submission).toBe('canceled'); }); - 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, - }, - ], + it.each(['expired', 'nonce-consumed', 'venue-refusal'] as const)( + 'transfers exact %s attached non-acceptance before ledger retirement and permits explicit abandonment', + async (proof) => { + const { disk, infrastructure } = durableInfrastructure(); + const first = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + first.clientInstance.sendTx.mockRejectedValue( + proof === 'venue-refusal' + ? new LighterApiError('order refused', 21000) + : new Error('transport before acceptance'), + ); + expect((await first.provider.placeOrder(attachedIntent)).success).toBe( + false, + ); + const { + groups: [group], + } = storedAttached(disk); + if (group.expiresAt === null || group.nonce === null) { + throw new Error('Missing dispatch identity'); + } + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt + 700000); + try { + first.clientInstance.getTx.mockResolvedValue(null); + first.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce + (proof === 'nonce-consumed' ? 1 : 0), + }); + expect( + await first.provider.reconcileRecoveredDispatches(), + ).toStrictEqual([]); + const restarted = buildProvider({ + apiKeyIndex: 19, + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex: 19, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], + }); + expect( + (await restarted.provider.getAttachedOrderGroups())[0].groupId, + ).toBe(group.groupId); + const canceled = await restarted.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }); + expect(canceled.error).toBeUndefined(); + expect(canceled.success).toBe(true); + expect( + restarted.calls.filter( + (call) => + call.function === '_signCancelOrder' || + call.function === '_signCreateGroupedOrders', + ), + ).toStrictEqual([]); + expect( + (await restarted.provider.placeOrder(attachedIntent)).success, + ).toBe(true); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(1); + } finally { + clock.mockRestore(); + } + }, + ); + + it.each( + (['accepted', 'failed'] as const).flatMap((outcome) => + (['matching', 'account', 'slot', 'nonce', 'hash'] as const).map( + (identity) => ({ outcome, identity }), + ), + ), + )( + 'settles attached $outcome only for exact $identity identity', + async ({ outcome, identity }) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + built.clientInstance.sendTx.mockRejectedValue( + new Error('lost response'), + ); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: identity === 'hash' ? 'deadbeef' : group.txHash, + accountIndex: identity === 'account' ? 29 : 28, + apiKeyIndex: identity === 'slot' ? 19 : 7, + nonce: identity === 'nonce' ? 999 : group.nonce, + status: outcome === 'accepted' ? 2 : 0, + }); + let cancellationSuccess: boolean | undefined; + if (outcome === 'accepted') { + await built.provider.reviewAttachedOrderGroups(); + } else { + const result = await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }); + cancellationSuccess = result.success; + } + expect(cancellationSuccess).toBe( + outcome === 'accepted' ? undefined : identity === 'matching', + ); + const terminal = outcome === 'accepted' ? 'accepted' : 'canceled'; + expect( + (await built.provider.getAttachedOrderGroups())[0].submission, + ).toBe(identity === 'matching' ? terminal : 'unknown'); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + }, + ); + + it('persists exact terminal attached completion and skips subsequent venue reads and unchanged writes', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - // 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 venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + venue.inactive.push( + ...venue.active + .splice(0) + .map((row) => ({ ...row, status: 'canceled' })), ); - 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); + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('completed'); + expect(storedAttached(disk).groups[0].submission).toBe('completed'); + built.clientInstance.getActiveOrders.mockClear(); + built.clientInstance.getInactiveOrders.mockClear(); + jest.mocked(infrastructure.diskCache).setItem.mockClear(); expect( - calls.filter((call) => call.function === '_signCreateGroupedOrders'), - ).toHaveLength(0); + (await built.provider.reviewAttachedOrderGroups())[0].submission, + ).toBe('completed'); + expect(built.clientInstance.getActiveOrders).not.toHaveBeenCalled(); + expect(built.clientInstance.getInactiveOrders).not.toHaveBeenCalled(); + expect( + jest.mocked(infrastructure.diskCache).setItem.mock.calls, + ).toStrictEqual([]); }); - }); - describe('account-type gate', () => { - it('refuses Premium (nonzero-fee) accounts across the account surface', async () => { - const { provider, clientInstance } = buildProvider({ - configuredAccountIndex: null, + it('rejects oversized inactive containers before attached cancellation signs', async () => { + const { infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - clientInstance.getAccountsByL1Address.mockResolvedValue({ + const venue = attachedVenue(built); + const placed = await built.provider.placeOrder(attachedIntent); + const unrelated = { + ...venue.active[0], + clientOrderIndex: 7, + orderIndex: 7, + }; + built.clientInstance.getInactiveOrders.mockResolvedValue({ code: 200, - l1Address: ACCOUNT.l1Address, - subAccounts: [{ ...ACCOUNT, accountType: 1 }], + orders: Array.from({ length: 101 }, () => unrelated), }); - // 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 result = await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: placed.attachedOrderGroup?.groupId ?? '', }); - const ready = await provider.isReadyToTrade(); - expect(ready.ready).toBe(false); - expect(ready.error).toContain('Premium'); + expect(result.success).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); }); - it('fails closed when the account type cannot be verified', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ - code: 200, - accounts: [], + it.each(['terminal', 'unproven', 'one-unknown'] as const)( + 'reclaims full attached capacity only with exact terminal proof: %s', + async (scenario) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const { + key, + groups: [original], + } = storedAttached(disk); + const groups = Array.from({ length: 64 }, (_, index) => { + const copy = JSON.parse( + JSON.stringify(original), + ) as LighterAttachedGroup; + const marketIndex = index < 32 ? 1 : 2; + for (const [leg, wire] of copy.orders.entries()) { + wire[0] = marketIndex; + wire[1] = 10000 + index * 10 + leg; + } + copy.groupId = original.groupId.replace( + /:\d+:\d+$/u, + `:${marketIndex}:${copy.orders[0][1]}`, + ); + copy.venueIds = [null, null, null]; + return copy; + }); + if (scenario === 'one-unknown') { + groups[63].submission = 'unknown'; + } + disk.set(key, JSON.stringify(groups)); + const terminalRows = groups.flatMap((group, index) => + venue.active.map((row, leg) => ({ + ...row, + clientOrderIndex: group.orders[leg][1], + marketIndex: group.orders[leg][0], + orderIndex: 1000 + index * 10 + leg, + status: 'canceled', + remainingBaseAmount: '0', + parentOrderIndex: undefined, + toTriggerOrderId0: undefined, + toTriggerOrderId1: undefined, + })), + ); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getInactiveOrders.mockImplementation( + async (_account, _token, _limit, _cursor, market) => ({ + code: 200, + orders: + scenario === 'unproven' + ? [] + : terminalRows.filter( + (row) => + row.marketIndex === market && + (scenario !== 'one-unknown' || + row.clientOrderIndex < 10630), + ), + }), + ); + built.clientInstance.getTx.mockResolvedValue(null); + await built.provider.reviewAttachedOrderGroups(); + expect(built.clientInstance.getActiveOrders).toHaveBeenCalledTimes(2); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(2); + const result = await built.provider.placeOrder(attachedIntent); + expect(result.success).toBe(scenario === 'terminal'); + const retained = storedAttached(disk).groups; + expect(retained).toHaveLength(64); + expect( + retained.some((group) => group.groupId === groups[63].groupId), + ).toBe(true); + expect( + retained.some((group) => group.groupId === groups[0].groupId), + ).toBe(scenario !== 'terminal'); + }, + ); + + it.each(['active', 'inactive'] as const)( + 'rejects attached review session drift during the shared %s read', + async (boundary) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const before = storedAttached(disk); + const drift = async (): Promise<{ + code: number; + orders: LighterApiOrder[]; + }> => { + await built.provider.disconnect(); + return { code: 200, orders: [...venue.active] }; + }; + if (boundary === 'active') { + built.clientInstance.getActiveOrders.mockImplementationOnce(drift); + } else { + built.clientInstance.getInactiveOrders.mockImplementationOnce(drift); + } + await expect( + built.provider.reviewAttachedOrderGroups(), + ).rejects.toThrow('cancelled'); + expect(storedAttached(disk)).toStrictEqual(before); + }, + ); + + it.each(['oversized', 'contradictory'] as const)( + 'rejects shared attached review containers with %s evidence', + async (evidence) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + const before = storedAttached(disk); + if (evidence === 'oversized') { + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: Array.from({ length: 101 }, () => venue.active[0]), + }); + } else { + venue.active[1].isAsk = !venue.active[1].isAsk; + } + await expect( + built.provider.reviewAttachedOrderGroups(), + ).rejects.toThrow( + evidence === 'oversized' + ? 'bounded authoritative' + : 'does not match signed intent', + ); + expect(storedAttached(disk)).toStrictEqual(before); + }, + ); + + it('does not rewrite unchanged accepted observations', async () => { + const { infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - const ready = await provider.isReadyToTrade(); - expect(ready.ready).toBe(false); - expect(ready.error).toContain('could not be verified'); + attachedVenue(built); + await built.provider.placeOrder(attachedIntent); + await built.provider.reviewAttachedOrderGroups(); + jest.mocked(infrastructure.diskCache).setItem.mockClear(); + await built.provider.reviewAttachedOrderGroups(); + expect( + jest + .mocked(infrastructure.diskCache) + .setItem.mock.calls.filter(([key]) => + key.startsWith('lighterAttachedOrders:'), + ), + ).toStrictEqual([]); }); - it('gates calculateFees for non-Standard accounts', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ + it.each(['absent-unexpired', 'pending', 'lookup-failure'] as const)( + 'quarantines attached non-acceptance ambiguity: %s', + async (ambiguity) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + built.clientInstance.sendTx.mockRejectedValue( + new Error('lost response'), + ); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + if (group.nonce === null) { + throw new Error('Missing nonce'); + } + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: group.nonce, + }); + built.clientInstance.getTx.mockResolvedValue( + ambiguity === 'pending' + ? { + code: 200, + hash: group.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: group.nonce, + status: 1, + } + : null, + ); + if (ambiguity === 'lookup-failure') { + built.clientInstance.getTx.mockRejectedValue( + new Error('lookup unavailable'), + ); + } + await built.provider.reconcileRecoveredDispatches(); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(false); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + expect(storedAttached(disk).groups[0].submission).toBe('unknown'); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); + }, + ); + + it('preserves nonce evidence when attached settlement persistence fails', async () => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + built.clientInstance.sendTx.mockRejectedValue(new Error('lost response')); + await built.provider.placeOrder(attachedIntent); + const before = new Map(disk); + const { + key, + groups: [group], + } = storedAttached(disk); + if (group.nonce === null) { + throw new Error('Missing nonce'); + } + built.clientInstance.getTx.mockResolvedValue(null); + built.clientInstance.getNextNonce.mockResolvedValue({ code: 200, - accounts: [{ ...ACCOUNT, accountType: 1 }], + nonce: group.nonce + 1, }); - await expect( - provider.calculateFees({ - orderType: 'market', - symbol: 'BTC', - amount: '100', - }), - ).rejects.toThrow('Premium'); + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (storageKey, value) => { + if (storageKey === key) { + throw new Error('disk full'); + } + disk.set(storageKey, value); + }); + if (group.expiresAt === null) { + throw new Error('Missing expiry'); + } + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(group.expiresAt + 700000); + try { + await expect( + built.provider.reconcileRecoveredDispatches(), + ).rejects.toThrow('disk full'); + expect(disk).toStrictEqual(before); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + } finally { + clock.mockRestore(); + } }); - }); - 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); - }, + it.each( + (['failed', 'expired', 'nonce-consumed'] as const).flatMap((proof) => + (['slot', 'nonce', 'hash', 'group', 'expiry'] as const).map( + (identity) => ({ proof, identity }), + ), + ), + )( + 'retains attached nonce evidence for $proof when durable $identity mismatches', + async ({ proof, identity }) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + built.clientInstance.sendTx.mockRejectedValue( + new Error('lost response'), + ); + await built.provider.placeOrder(attachedIntent); + const { + key, + groups: [group], + } = storedAttached(disk); + if (group.nonce === null) { + throw new Error('Missing nonce'); + } + const { nonce, txHash, expiresAt } = group; + if (expiresAt === null) { + throw new Error('Missing expiry'); + } + if (identity === 'expiry') { + group.expiresAt = expiresAt + 1; + } + if (identity === 'slot') { + group.apiKeyIndex = 19; + } + if (identity === 'nonce') { + group.nonce += 1; + } + if (identity === 'hash') { + group.txHash = 'deadbeef'; + } + if (identity === 'group') { + group.orders.forEach((wire) => { + wire[1] += 10; + }); + group.groupId = group.groupId.replace( + /:\d+$/u, + `:${group.orders[0][1]}`, + ); + } + disk.set(key, JSON.stringify([group])); + const before = new Map(disk); + built.clientInstance.getTx.mockResolvedValue( + proof === 'failed' + ? { + code: 200, + hash: txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce, + status: 0, + } + : null, + ); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: nonce + (proof === 'nonce-consumed' ? 1 : 0), + }); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(expiresAt + 700000); + try { + const result = await built.provider.reconcileRecoveredDispatches(); + expect(result).toHaveLength(1); + expect(result[0].acknowledgeable).toBe(false); + expect(disk).toStrictEqual(before); + expect(storedAttached(disk).groups[0].nonAcceptance).toBeUndefined(); + } finally { + clock.mockRestore(); + } + }, + ); + + it('persists unsigned parent and child identities before signing, then lists them after restart in another slot', async () => { + const { infrastructure, disk } = durableInfrastructure(); + const first = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const execute = jest.mocked(first.bridge).execute.getMockImplementation(); + if (!execute) { + throw new Error('Missing mock signer'); + } + let storedBeforeSigning: { + submission: string; + txHash: null; + orders: unknown[][]; + }[] = []; + jest.mocked(first.bridge).execute.mockImplementation(async (call) => { + if (call.function === '_signCreateGroupedOrders') { + const stored = [...disk.entries()].find(([key]) => + key.startsWith('lighterAttachedOrders:'), + ); + storedBeforeSigning = JSON.parse( + stored?.[1] ?? '[]', + ) as typeof storedBeforeSigning; + } + return execute(call); + }); + const result = await first.provider.placeOrder(attachedIntent); + expect(result.error).toBeUndefined(); + expect(result.attachedOrderGroup?.submission).toBe('accepted'); + expect(storedBeforeSigning).toHaveLength(1); + expect(storedBeforeSigning[0].submission).toBe('prepared'); + expect(storedBeforeSigning[0].txHash).toBeNull(); + expect( + storedBeforeSigning[0].orders.map((order) => order[8]), + ).toStrictEqual(['0', '1100000', '800000']); + const second = buildProvider({ + platformDependencies: infrastructure, + apiKeyIndex: 8, + }); + expect(await second.provider.getAttachedOrderGroups()).toStrictEqual([ + result.attachedOrderGroup, + ]); + expect(second.calls).toHaveLength(0); + expect(second.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('retains unknown acceptance and blocks replay across restart and trading-key migration', async () => { + const { infrastructure } = durableInfrastructure(); + const first = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + first.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), ); - const result = await provider.placeOrder({ + const result = await first.provider.placeOrder(attachedIntent); + expect(result.success).toBe(false); + expect(result.attachedOrderGroup?.submission).toBe('unknown'); + const second = buildProvider({ + platformDependencies: infrastructure, + apiKeyIndex: 8, + registeredKey: '9c'.repeat(40), + }); + expect((await second.provider.getAttachedOrderGroups())[0].groupId).toBe( + result.attachedOrderGroup?.groupId, + ); + const retry = await second.provider.placeOrder(attachedIntent); + expect(retry.success).toBe(false); + expect( + second.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(0); + expect(second.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it.each(['signing', 'prepared-storage', 'unknown-storage'] as const)( + 'refuses dispatch after account A-to-B-to-A during %s', + async (boundary) => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const held = createDeferred(); + const entered = createDeferred(); + const execute = jest + .mocked(bundle.bridge) + .execute.getMockImplementation(); + if (!execute) { + throw new Error('Missing mock signer'); + } + const write = jest + .mocked(infrastructure.diskCache) + .setItem.getMockImplementation(); + if (!write) { + throw new Error('Missing mock storage'); + } + jest.mocked(bundle.bridge).execute.mockImplementation(async (call) => { + const result = await execute(call); + if ( + boundary === 'signing' && + call.function === '_signCreateGroupedOrders' + ) { + entered.resolve(); + await held.promise; + } + return result; + }); + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + await write(key, value); + if ( + key.startsWith('lighterAttachedOrders:') && + value.includes( + `"submission":"${boundary === 'prepared-storage' ? 'prepared' : 'unknown'}"`, + ) && + boundary !== 'signing' + ) { + entered.resolve(); + await held.promise; + } + }); + const pending = bundle.provider.placeOrder(attachedIntent); + await entered.promise; + bundle.getUserAddressMock.mockReturnValue( + '0x1111111111111111111111111111111111111111', + ); + await expect(bundle.provider.getAttachedOrderGroups()).rejects.toThrow( + 'not owned', + ); + bundle.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + await bundle.provider.getAttachedOrderGroups(); + held.resolve(); + const result = await pending; + expect(result.success).toBe(false); + expect(result.error).toContain('cancelled'); + expect(bundle.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('fails closed when durable intent storage fails before grouped signing', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const write = jest + .mocked(infrastructure.diskCache) + .setItem.getMockImplementation(); + if (!write) { + throw new Error('Missing mock storage'); + } + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + if (key.startsWith('lighterAttachedOrders:')) { + throw new Error('disk full'); + } + await write(key, value); + }); + const result = await bundle.provider.placeOrder(attachedIntent); + expect(result.error).toContain('disk full'); + expect( + bundle.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(0); + expect(bundle.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('reviews waiting children and a partially filled parent using exact identities and linkage', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + await bundle.provider.placeOrder(attachedIntent); + venue.active[0].filledBaseAmount = '0.0004'; + venue.active[0].remainingBaseAmount = '0.0006'; + const writes = bundle.clientInstance.sendTx.mock.calls.length; + const result = await bundle.provider.reviewAttachedOrderGroups(); + expect(result[0]).toMatchObject({ + linkage: 'confirmed', + parentOrderId: '500', + childOrderIds: ['501', '502'], + }); + expect(result[0].orders?.map((order) => order.status)).toStrictEqual([ + 'partially-filled', + 'waiting', + 'waiting', + ]); + expect(result[0].orders?.[0].filledSize).toBe('0.0004'); + expect(bundle.clientInstance.sendTx).toHaveBeenCalledTimes(writes); + }); + + it('does not confirm contradictory parent linkage even when the parent lists the child', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + await bundle.provider.placeOrder(attachedIntent); + venue.active[1].parentOrderIndex = 999; + const review = await bundle.provider.reviewAttachedOrderGroups(); + expect(review[0].linkage).toBe('unknown'); + }); + + it('keeps missing children unknown instead of using an unrelated new trigger', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + await bundle.provider.placeOrder(attachedIntent); + venue.active[1].clientOrderIndex += 1; + venue.active[1].orderIndex = 999; + const result = await bundle.provider.reviewAttachedOrderGroups(); + expect(result[0].orders?.[1]).toMatchObject({ status: 'unknown' }); + expect(result[0].childOrderIds).toBeUndefined(); + expect(result[0].linkage).toBe('unknown'); + }); + + it('cancels only the exact owned parent and children, preserving an unrelated trigger', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + const placed = await bundle.provider.placeOrder(attachedIntent); + venue.active.push({ + ...venue.active[1], + orderIndex: 999, + clientOrderIndex: 12345, + parentOrderIndex: undefined, + }); + venue.active[0].filledBaseAmount = '0.0004'; + venue.active[0].remainingBaseAmount = '0.0006'; + const result = await bundle.provider.cancelOrder({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', - leverage: 10, + orderId: placed.attachedOrderGroup?.groupId ?? '', }); + expect(result.error).toBeUndefined(); 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); + expect( + bundle.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['500', '501', '502']); + expect(venue.active.map((row) => row.orderIndex)).toStrictEqual([999]); + expect( + (await bundle.provider.getAttachedOrderGroups())[0].submission, + ).toBe('canceled'); }); - }); - 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, + it('refuses a group cancellation with missing owned identities before cancel signing', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + const placed = await bundle.provider.placeOrder(attachedIntent); + venue.active.pop(); + const result = await bundle.provider.cancelOrder({ + symbol: 'BTC', + orderId: placed.attachedOrderGroup?.groupId ?? '', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('unresolved'); + expect( + bundle.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(venue.active).toHaveLength(2); }); - 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] }; + + it('retains prepared intent after signer failure and requires explicit abandonment before a fresh placement', async () => { + const { infrastructure } = durableInfrastructure(); + const first = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const execute = jest.mocked(first.bridge).execute.getMockImplementation(); + if (!execute) { + throw new Error('Missing signer'); } - return { code: 200, orders: [...rawTriggers] }; + jest.mocked(first.bridge).execute.mockImplementation(async (call) => { + if (call.function === '_signCreateGroupedOrders') { + throw new Error('signer interrupted'); + } + return execute(call); + }); + const failed = await first.provider.placeOrder(attachedIntent); + expect(failed.attachedOrderGroup?.submission).toBe('prepared'); + expect(first.clientInstance.sendTx).not.toHaveBeenCalled(); + const second = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + expect((await second.provider.placeOrder(attachedIntent)).success).toBe( + false, + ); + expect( + second.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(0); + const canceled = await second.provider.cancelOrder({ + symbol: 'BTC', + orderId: failed.attachedOrderGroup?.groupId ?? '', + }); + expect(canceled.success).toBe(true); + expect( + second.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect((await second.provider.placeOrder(attachedIntent)).success).toBe( + true, + ); + expect( + second.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(1); }); - 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) } - : {}), - }; + + it('does not cancel again when parent cancellation already canceled its children', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + const placed = await bundle.provider.placeOrder(attachedIntent); + const send = bundle.clientInstance.sendTx.getMockImplementation(); + if (!send) { + throw new Error('Missing venue'); + } + bundle.clientInstance.sendTx.mockImplementation(async (kind: number) => { + const result: unknown = await send(kind); + if (kind === 15) { + venue.inactive.push( + ...venue.active.splice(0).map((row) => ({ + ...row, + status: 'canceled-child', + remainingBaseAmount: '0', + })), + ); + } + return result; + }); + const result = await bundle.provider.cancelOrder({ + symbol: 'BTC', + orderId: placed.attachedOrderGroup?.groupId ?? '', + }); + expect(result.success).toBe(true); + expect( + bundle.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['500']); + }); + + it('refuses unknown group handles before signer setup', async () => { + const bundle = buildProvider(); + expect( + ( + await bundle.provider.cancelOrder({ + symbol: 'BTC', + orderId: 'lighter-attached:other-account', + }) + ).success, + ).toBe(false); + expect(bundle.calls).toHaveLength(0); + }); + + it.each([ + 'matching', + 'account', + 'slot', + 'nonce', + 'hash', + 'absent', + ] as const)( + 'retires failed attached dispatch after the acceptance write failed only for %s evidence', + async (identity) => { + const { disk, infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const write = jest + .mocked(infrastructure.diskCache) + .setItem.getMockImplementation(); + if (!write) { + throw new Error('Missing storage'); + } + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + if ( + key.startsWith('lighterAttachedOrders:') && + value.includes('"submission":"accepted"') + ) { + throw new Error('accepted write failed'); + } + await write(key, value); + }); + await built.provider.placeOrder(attachedIntent); + const { + groups: [group], + } = storedAttached(disk); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); + built.clientInstance.getTx.mockResolvedValue( + identity === 'absent' + ? null + : { + code: 200, + hash: identity === 'hash' ? 'deadbeef' : group.txHash, + accountIndex: identity === 'account' ? 29 : 28, + apiKeyIndex: identity === 'slot' ? 19 : 7, + nonce: identity === 'nonce' ? 999 : group.nonce, + status: 0, + }, + ); + expect( + ( + await built.provider.cancelOrder({ + symbol: 'BTC', + orderId: group.groupId, + }) + ).success, + ).toBe(identity === 'matching'); + expect(storedAttached(disk).groups[0].submission).toBe( + identity === 'matching' ? 'canceled' : 'unknown', + ); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toStrictEqual([]); }, ); - const beginLag = (): void => { - if (restLag > 0) { - laggedView = [...rawTriggers]; - lagRemaining = restLag; + + it('retains accepted ownership when post-dispatch persistence fails', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const write = jest + .mocked(infrastructure.diskCache) + .setItem.getMockImplementation(); + if (!write) { + throw new Error('Missing storage'); } - }; - 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 0 (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: 2 }); - 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' }); + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + if ( + key.startsWith('lighterAttachedOrders:') && + value.includes('"submission":"accepted"') + ) { + throw new Error('post-send disk failure'); } - } 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 }); - } + await write(key, value); + }); + const placed = await bundle.provider.placeOrder(attachedIntent); + expect(placed.success).toBe(false); + expect(placed.attachedOrderGroup?.submission).toBe('accepted'); + const second = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), }); - }; - const commitCancel = (staged: StagedCancel): void => { - beginLag(); - landedTxs.set(staged.txHash, { nonce: staged.nonce, status: 2 }); - const at = rawTriggers.findIndex( - (entry) => String(entry.orderIndex) === String(staged.orderId), + expect( + (await second.provider.getAttachedOrderGroups())[0].submission, + ).toBe('unknown'); + expect((await second.provider.placeOrder(attachedIntent)).success).toBe( + false, ); - if (at >= 0) { - rawTriggers.splice(at, 1); + expect( + second.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(0); + }); + + it('does not erase accepted live children when acceptance persistence failed and transaction history later omits the hash', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + const write = jest + .mocked(infrastructure.diskCache) + .setItem.getMockImplementation(); + if (!write) { + throw new Error('Missing storage'); } - }; - // 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 => { + jest + .mocked(infrastructure.diskCache) + .setItem.mockImplementation(async (key, value) => { + if ( + key.startsWith('lighterAttachedOrders:') && + value.includes('"submission":"accepted"') + ) { + throw new Error('post-send disk failure'); + } + await write(key, value); + }); + const placed = await bundle.provider.placeOrder(attachedIntent); + const now = Date.now(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(now + 700000); try { - const parsed = (JSON.parse(txInfo) as Record).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); + const result = await bundle.provider.cancelOrder({ + symbol: 'BTC', + orderId: placed.attachedOrderGroup?.groupId ?? '', + }); + expect(result.success).toBe(true); + expect(venue.active).toHaveLength(0); + expect( + bundle.calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => call.params[2]), + ).toStrictEqual(['500', '501', '502']); + } finally { + clock.mockRestore(); } - }; - 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: 0, - }); - } - 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); - } + }); + + it.each( + (['prepared', 'unknown', 'accepted'] as const).flatMap((submission) => + (['absent', 'not-found', 'premium'] as const).map((account) => ({ + submission, + account, + })), + ), + )( + 'lists $submission attached ownership after restart with $account account state', + async ({ submission, account }) => { + const { disk, infrastructure } = durableInfrastructure(); + const first = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + if (submission === 'prepared') { + const execute = jest + .mocked(first.bridge) + .execute.getMockImplementation(); + if (!execute) { + throw new Error('Missing mock signer'); } - if (txType === 15) { - const staged = takeStagedCancel(txInfo); - if (staged) { - setTimeout(() => { - venueNonce += 1; - commitCancel(staged); - }, delayMs); + jest.mocked(first.bridge).execute.mockImplementation(async (call) => { + if (call.function === '_signCreateGroupedOrders') { + throw new Error('signer interrupted'); } - } - 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); - } + return execute(call); + }); + } else if (submission === 'unknown') { + first.clientInstance.sendTx.mockRejectedValue( + new Error('response lost'), + ); } - if (txType === 15) { - const staged = takeStagedCancel(txInfo); - if (staged) { - commitCancel(staged); - } + const placed = await first.provider.placeOrder(attachedIntent); + expect(placed.attachedOrderGroup?.submission).toBe(submission); + const before = await first.provider.getAttachedOrderGroups(); + expect(before).toHaveLength(1); + const bytes = [...disk.entries()]; + const restarted = buildProvider({ + platformDependencies: infrastructure, + configuredAccountIndex: null, + }); + restarted.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: + account === 'premium' ? [{ ...ACCOUNT, accountType: 1 }] : [], + }); + if (account === 'not-found') { + restarted.clientInstance.getAccountsByL1Address.mockRejectedValue( + new LighterApiError('account not found', 21100), + ); } - 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: 2 }); - } + expect(await restarted.provider.getAttachedOrderGroups()).toStrictEqual( + before, + ); + expect(restarted.calls).toStrictEqual([]); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(restarted.clientInstance.getNextNonce).not.toHaveBeenCalled(); + expect([...disk.entries()]).toStrictEqual(bytes); + }, + ); + + it.each(['transport', 'wrong-wallet', 'unknown-type', 'storage'] as const)( + 'refuses attached inventory on %s failure without hiding it as empty', + async (failure) => { + const { infrastructure } = durableInfrastructure(); + const built = buildProvider({ + platformDependencies: infrastructure, + configuredAccountIndex: null, + }); + let message: string; + if (failure === 'transport') { + message = 'inventory transport failed'; + built.clientInstance.getAccountsByL1Address.mockRejectedValue( + new Error(message), + ); + } else if (failure === 'storage') { + message = 'attached storage failed'; + jest + .mocked(infrastructure.diskCache) + .getItem.mockRejectedValue(new Error(message)); + } else { + message = + failure === 'wrong-wallet' + ? 'not owned' + : 'type could not be verified'; + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [ + { + ...ACCOUNT, + ...(failure === 'wrong-wallet' + ? { l1Address: '0x0000000000000000000000000000000000000002' } + : { accountType: 99 }), + }, + ], + }); } - if (failAfterCommit.has(txType)) { - failAfterCommit.delete(txType); - throw new Error('transport failure after venue commit'); + await expect(built.provider.getAttachedOrderGroups()).rejects.toThrow( + message, + ); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('preserves activated attached children during position protection removal', async () => { + const { infrastructure } = durableInfrastructure(); + const bundle = buildProvider({ + platformDependencies: infrastructure, + registeredKey: '9c'.repeat(40), + }); + const venue = attachedVenue(bundle); + await bundle.provider.placeOrder(attachedIntent); + for (const child of venue.active.slice(1)) { + child.initialBaseAmount = '0.1'; + child.remainingBaseAmount = '0.1'; + child.status = 'open'; + } + const removed = await bundle.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(removed.error).toBeUndefined(); + expect(removed.success).toBe(true); + expect( + bundle.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(venue.active.map((row) => row.orderIndex)).toStrictEqual([ + 500, 501, 502, + ]); + }); + + it('keeps lifecycle guarantees pending in capabilities', async () => { + const { provider } = buildProvider(); + const result = await provider.getOrderCapabilities({ symbol: 'BTC' }); + expect(result.status).toBe('ready'); + expect(result).toMatchObject({ + attachedTpsl: { + lifecycleVerification: 'pending', + partialSizes: false, + childCoverage: 'venue-native-zero-size', + }, + }); + }); + + it.each([ + ['market', undefined, { stopLossPrice: '80000' }, 1, 2], + ['market', undefined, { takeProfitPrice: '110000' }, 1, 2], + ['limit', undefined, { stopLossPrice: '80000' }, 1, 2], + ['limit', 'IOC', { takeProfitPrice: '110000' }, 1, 2], + [ + 'market', + undefined, + { takeProfitPrice: '110000', stopLossPrice: '80000' }, + 3, + 3, + ], + [ + 'limit', + undefined, + { takeProfitPrice: '110000', stopLossPrice: '80000' }, + 3, + 3, + ], + ] as const)( + 'signs one native %s group with %s and %j', + async (orderType, timeInForce, triggers, grouping, count) => { + const { provider, calls, clientInstance } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType, + price: '90000', + timeInForce, + ...triggers, + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + const groups = calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ); + expect(groups).toHaveLength(1); + expect(groups[0].params).toHaveLength(4 + count * 10); + expect(groups[0].params.slice(1, 3)).toStrictEqual([grouping, count]); + expect(groups[0].params[5]).toBe('100'); + for (let child = 1; child < count; child += 1) { + expect(groups[0].params[5 + child * 10]).toBe('0'); + expect(groups[0].params[7 + child * 10]).toBe(1); + expect(groups[0].params[10 + child * 10]).toBe(1); } - const codedFailure = failCodedAfterCommit.get(txType); - if (codedFailure !== undefined) { - failCodedAfterCommit.delete(txType); - throw new LighterApiError('internal server error', codedFailure); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect( + clientInstance.sendTx.mock.calls.filter(([kind]) => kind === 28), + ).toHaveLength(1); + }, + ); + + it.each([ + { takeProfitSize: '0.0005' }, + { stopLossSize: '0' }, + { tpslLinkage: 'position' }, + { tpslLinkage: 'none' }, + { grouping: 'positionTpsl' }, + { takeProfitPrice: '' }, + { takeProfitPrice: '110000.01' }, + { reduceOnly: true }, + { clientOrderId: 'explicit-caller-id' }, + ] as const)( + 'rejects unsupported attached intent before signer setup: %j', + async (invalid) => { + const { provider, calls, clientInstance } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + takeProfitPrice: '110000', + ...invalid, + }; + expect((await provider.validateOrder(params)).isValid).toBe(false); + expect((await provider.placeOrder(params)).success).toBe(false); + expect(calls).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + }); + + 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, resetListenerCount, resetUnsubscribe } = + buildProvider(); + expect(resetListenerCount()).toBe(1); + expect(await provider.disconnect()).toStrictEqual({ + success: true, + }); + expect(resetUnsubscribe).toHaveBeenCalledTimes(1); + expect(resetListenerCount()).toBe(0); + }); + + it('does not rebind when a delayed account lookup resolves after disconnect', async () => { + const { provider, clientInstance, calls, resetListenerCount } = + buildProvider(); + let resolveAccount: (value: unknown) => void = () => undefined; + let signalAccountLookup: () => void = () => undefined; + const accountLookupStarted = new Promise((resolve) => { + signalAccountLookup = resolve; + }); + clientInstance.getAccountByIndex + .mockImplementationOnce(() => { + signalAccountLookup(); + return new Promise((resolve) => { + resolveAccount = resolve; + }); + }) + .mockResolvedValue({ code: 200, accounts: [ACCOUNT] }); + + provider.subscribeToAccount({ callback: jest.fn() }); + await accountLookupStarted; + await provider.disconnect(); + expect(resetListenerCount()).toBe(0); + resolveAccount({ code: 200, accounts: [ACCOUNT] }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(resetListenerCount()).toBe(0); + expect( + calls.filter((call) => call.function === '_createClient'), + ).toHaveLength(0); + }); + + it('does not recreate the signer when a delayed auth mint resolves after disconnect', async () => { + const { provider, bridge, calls, resetListenerCount } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + const realExecute = ( + bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let releaseAuth: () => void = () => undefined; + let signalAuthMint: () => void = () => undefined; + const authMintStarted = new Promise((resolve) => { + signalAuthMint = resolve; + }); + let authPaused = false; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_createAuthToken' && !authPaused) { + authPaused = true; + signalAuthMint(); + await new Promise((resolve) => { + releaseAuth = resolve; + }); + } + return await realExecute(call); + }, + ); + + provider.subscribeToOrders({ callback: jest.fn() }); + await authMintStarted; + expect( + calls.filter((call) => call.function === '_createClient'), + ).toHaveLength(1); + await provider.disconnect(); + releaseAuth(); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(resetListenerCount()).toBe(0); + expect( + calls.filter((call) => call.function === '_createClient'), + ).toHaveLength(1); + }); + + it('ignores a WebSocket open that arrives after disconnect', async () => { + StreamFakeWebSocket.instances = []; + const { provider, calls, resetListenerCount } = buildProvider({ + webSocketCtor: fakeStreamCtor, + }); + const callback = jest.fn(); + provider.subscribeToPrices({ symbols: [], callback }); + const socket = StreamFakeWebSocket.instances[0]; + await provider.disconnect(); + + socket.open(); + + expect(resetListenerCount()).toBe(0); + expect(socket.readyState).toBe(3); + expect(provider.getWebSocketConnectionState()).toBe('disconnected'); + expect(socket.sent).toStrictEqual([]); + expect(callback).not.toHaveBeenCalled(); + expect( + calls.filter((call) => call.function === '_createClient'), + ).toHaveLength(0); + }); + + it('does not repopulate the market cache when initialization resolves after disconnect', async () => { + const { provider, clientInstance } = buildProvider(); + let resolveMarkets: (value: unknown) => void = () => undefined; + let signalMarketRead: () => void = () => undefined; + const marketReadStarted = new Promise((resolve) => { + signalMarketRead = resolve; + }); + clientInstance.getOrderBooks.mockImplementationOnce(() => { + signalMarketRead(); + return new Promise((resolve) => { + resolveMarkets = resolve; + }); + }); + const pendingInitialize = provider.initialize(); + await marketReadStarted; + await provider.disconnect(); + + resolveMarkets([BTC_MARKET]); + expect(await pendingInitialize).toStrictEqual({ + success: false, + error: 'Lighter provider is disconnected', + }); + expect( + await provider.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h', + }), + ).toStrictEqual({ symbol: 'BTC', interval: '1h', candles: [] }); + expect(clientInstance.getOrderBooks).toHaveBeenCalledTimes(2); + expect(clientInstance.getCandles).not.toHaveBeenCalled(); + }); + + it('does not repopulate margin metadata when a fetch resolves after disconnect', async () => { + const { provider, clientInstance } = buildProvider(); + let resolveMargins: (value: unknown) => void = () => undefined; + let signalMarginRead: () => void = () => undefined; + const marginReadStarted = new Promise((resolve) => { + signalMarginRead = resolve; + }); + clientInstance.getOrderBookDetails + .mockImplementationOnce(() => { + signalMarginRead(); + return new Promise((resolve) => { + resolveMargins = resolve; + }); + }) + .mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + symbol: 'BTC', + lastTradePrice: 100000, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }); + const pendingLeverage = provider.getMaxLeverage('BTC'); + await marginReadStarted; + await provider.disconnect(); + + resolveMargins({ + code: 200, + orderBookDetails: [ + { + symbol: 'BTC', + lastTradePrice: 100000, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }); + await expect(pendingLeverage).rejects.toThrow( + 'margin metadata unavailable', + ); + await expect(provider.getMaxLeverage('BTC')).rejects.toThrow( + 'margin metadata unavailable', + ); + expect(clientInstance.getOrderBookDetails).toHaveBeenCalledTimes(2); + }); + + it('does not revive candle state or callbacks when history resolves after disconnect', async () => { + StreamFakeWebSocket.instances = []; + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeStreamCtor, + }); + let resolveCandles: (value: unknown) => void = () => undefined; + let signalCandleRead: () => void = () => undefined; + const candleReadStarted = new Promise((resolve) => { + signalCandleRead = resolve; + }); + clientInstance.getCandles + .mockImplementationOnce(() => { + signalCandleRead(); + return new Promise((resolve) => { + resolveCandles = resolve; + }); + }) + .mockResolvedValue({ code: 200, c: [] }); + const callback = jest.fn(); + provider.subscribeToCandles({ + symbol: 'BTC', + interval: '1h', + callback, + }); + await candleReadStarted; + await provider.disconnect(); + + resolveCandles({ + code: 200, + c: [{ t: 1000, o: 1, h: 2, l: 0.5, c: 1.5, v: 10 }], + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(callback).not.toHaveBeenCalled(); + expect(StreamFakeWebSocket.instances).toHaveLength(0); + const lateCallback = jest.fn(); + provider.subscribeToCandles({ + symbol: 'BTC', + interval: '1h', + callback: lateCallback, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(lateCallback).not.toHaveBeenCalled(); + expect(clientInstance.getCandles).toHaveBeenCalledTimes(1); + }); + + it('does not resume delayed order-book subscription setup after disconnect', async () => { + StreamFakeWebSocket.instances = []; + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeStreamCtor, + }); + let rejectMarkets: (error: Error) => void = () => undefined; + let signalMarketRead: () => void = () => undefined; + const marketReadStarted = new Promise((resolve) => { + signalMarketRead = resolve; + }); + clientInstance.getOrderBooks.mockImplementationOnce(() => { + signalMarketRead(); + return new Promise((_resolve, reject) => { + rejectMarkets = reject; + }); + }); + const callback = jest.fn(); + const onError = jest.fn(); + provider.subscribeToOrderBook({ symbol: 'BTC', callback, onError }); + await marketReadStarted; + await provider.disconnect(); + + rejectMarkets(new Error('late market lookup failure')); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(callback).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(StreamFakeWebSocket.instances).toHaveLength(0); + }); + + it('does not repopulate price replay state when a poll resolves after disconnect', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: null, + }); + let resolvePrices: (value: unknown) => void = () => undefined; + let signalPriceRead: () => void = () => undefined; + const priceReadStarted = new Promise((resolve) => { + signalPriceRead = resolve; + }); + clientInstance.getOrderBookDetails.mockImplementationOnce(() => { + signalPriceRead(); + return new Promise((resolve) => { + resolvePrices = resolve; + }); + }); + const callback = jest.fn(); + provider.subscribeToPrices({ symbols: [], callback }); + await priceReadStarted; + await provider.disconnect(); + + resolvePrices({ + code: 200, + orderBookDetails: [{ ...BTC_MARKET, lastTradePrice: 12345 }], + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(callback).not.toHaveBeenCalled(); + const lateCallback = jest.fn(); + provider.subscribeToPrices({ symbols: [], callback: lateCallback }); + expect(lateCallback).not.toHaveBeenCalled(); + expect(provider.getWebSocketConnectionState()).toBe('disconnected'); + expect(clientInstance.getOrderBookDetails).toHaveBeenCalledTimes(1); + }); + + 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('queries all API-key slots and registers the venue key when the configured slot is missing', async () => { + const { provider, clientInstance, calls, bridge } = buildProvider(); + const result = await provider.isReadyToTrade(); + expect(result.ready).toBe(true); + // A direct lookup of an unused slot returns venue error 21109 + // (`api key not found`); querying all slots returns an empty list. + expect(clientInstance.getApiKeys).toHaveBeenCalledWith(28); + expect(bridge.createClient).toHaveBeenCalledWith({ + chainId: 300, + accountIndex: 28, + nonce: 42, + apiKeyIndex: 7, + walletAddress: ACCOUNT.l1Address.toLowerCase(), + }); + 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(); + }); + + it('fails closed when the venue-key lookup fails', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getApiKeys.mockRejectedValue(new Error('temporary 503')); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('temporary 503'); + expect(calls.map((call) => call.function)).not.toContain( + '_signChangePubKey', + ); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('refuses to replace a different key already registered in the configured slot', async () => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: 'ab'.repeat(40), + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('supports device-key recovery'); + 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('surfaces malformed venue market data instead of reporting no markets', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getOrderBooks.mockRejectedValue( + new Error('Invalid Lighter venue data: malformed market'), + ); + await expect(provider.getMarkets()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + + 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'); + }); + + it('omits inactive markets without usable risk metadata while preserving active markets', async () => { + const built = buildProvider(); + const inactiveMarket = { + ...BTC_MARKET, + symbol: 'MKR', + marketId: 28, + status: 'inactive', + minBaseAmount: '0.0000', + minQuoteAmount: '0.000000', + }; + const activeDetail = { + ...BTC_MARKET, + lastTradePrice: 100000, + dailyTradesCount: 10, + dailyBaseTokenVolume: 1, + dailyQuoteTokenVolume: 100000, + dailyPriceLow: 99000, + dailyPriceHigh: 101000, + dailyPriceChange: 1, + openInterest: 1000000, + dailyChart: {}, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }; + built.clientInstance.getOrderBooks.mockResolvedValue([ + BTC_MARKET, + inactiveMarket, + ]); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + activeDetail, + { + ...activeDetail, + ...inactiveMarket, + defaultInitialMarginFraction: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }, + ], + }); + + const markets = await built.provider.getMarkets(); + const marketData = await built.provider.getMarketDataWithPrices(); + + expect(markets.map((market) => market.name)).toStrictEqual(['BTC']); + expect(marketData.map((market) => market.symbol)).toStrictEqual(['BTC']); + }); + + it('preserves inactive markets when their risk metadata remains usable', async () => { + const built = buildProvider(); + const inactiveMarket = { + ...BTC_MARKET, + symbol: 'MKR', + marketId: 28, + status: 'inactive', + }; + built.clientInstance.getOrderBooks.mockResolvedValue([inactiveMarket]); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...inactiveMarket, + lastTradePrice: 2500, + dailyTradesCount: 0, + dailyBaseTokenVolume: 0, + dailyQuoteTokenVolume: 0, + dailyPriceLow: 2500, + dailyPriceHigh: 2500, + dailyPriceChange: 0, + openInterest: 0, + dailyChart: {}, + minInitialMarginFraction: 500, + maintenanceMarginFraction: 250, + }, + ], + }); + + const markets = await built.provider.getMarkets(); + const marketData = await built.provider.getMarketDataWithPrices(); + + expect(markets).toHaveLength(1); + expect(markets[0]).toMatchObject({ + name: 'MKR', + isDelisted: true, + maxLeverage: 20, + }); + expect(marketData.map((market) => market.symbol)).toStrictEqual(['MKR']); + }); + + it('continues to reject zero risk metadata for active markets', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + dailyTradesCount: 10, + dailyBaseTokenVolume: 1, + dailyQuoteTokenVolume: 100000, + dailyPriceLow: 99000, + dailyPriceHigh: 101000, + dailyPriceChange: 1, + openInterest: 1000000, + dailyChart: {}, + defaultInitialMarginFraction: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }, + ], + }); + + await expect(built.provider.getMarkets()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + await expect(built.provider.getMarketDataWithPrices()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + + it('does not publish market metadata without verified per-market leverage', async () => { + const markets = buildProvider(); + markets.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], + }); + await expect(markets.provider.getMarkets()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + + const marketData = buildProvider(); + marketData.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + dailyPriceChange: 1, + }, + ], + }); + await expect( + marketData.provider.getMarketDataWithPrices(), + ).rejects.toThrow('Invalid Lighter venue data'); + }); + + it('rejects margin fractions that would derive leverage below 1x', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + minInitialMarginFraction: 10001, + maintenanceMarginFraction: 10001, + }, + ], + }); + await expect(built.provider.getMarkets()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + await expect(built.provider.getPositions()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + + it.each([Number.MIN_VALUE, 1.5])( + 'rejects non-integer margin fraction %p through market and position reads', + async (marginFraction) => { + const built = buildProvider(); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + minInitialMarginFraction: marginFraction, + maintenanceMarginFraction: marginFraction, + }, + ], + }); + await expect(built.provider.getMarkets()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + await expect(built.provider.getPositions()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }, + ); + + it('surfaces malformed successful order-book details instead of reporting no market data', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('Invalid Lighter venue data: malformed order-book details'), + ); + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + }); + + 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('preserves retired-market positions when risk metadata is zeroed', async () => { + const built = buildProvider(); + const inactiveMarket = { + ...BTC_MARKET, + symbol: 'MKR', + marketId: 28, + status: 'inactive', + minBaseAmount: '0.0000', + minQuoteAmount: '0.000000', + }; + built.clientInstance.getOrderBooks.mockResolvedValue([ + BTC_MARKET, + inactiveMarket, + ]); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + { + ...inactiveMarket, + lastTradePrice: 0, + minInitialMarginFraction: 0, + maintenanceMarginFraction: 0, + }, + ], + }); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + ...(ACCOUNT.positions ?? []), + { + ...ACCOUNT.positions?.[0], + marketId: 28, + symbol: 'MKR', + initialMarginFraction: '10', + }, + ], + }, + ], + }); + await built.provider.initialize(); + + const positions = await built.provider.getPositions(); + + expect(positions).toStrictEqual([ + expect.objectContaining({ symbol: 'BTC', maxLeverage: 50 }), + expect.objectContaining({ + symbol: 'MKR', + leverage: expect.objectContaining({ value: 10 }) as { value: number }, + maxLeverage: 10, + }), + ]); + }); + + it('surfaces margin metadata transport failures from position reads', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('margin metadata unavailable'), + ); + await expect(provider.getPositions()).rejects.toThrow( + 'margin metadata unavailable', + ); + expect(clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + }); + + 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('surfaces malformed REST account numbers instead of degrading to empty state', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, collateral: '10000USD' }], + }); + + await expect(provider.getAccountState()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + + 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('surfaces malformed REST order state instead of returning an empty list', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getActiveOrders.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: 'venue-added-state', + orderExpiry: 0, + timestamp: 1700000000000, + }, + ], + }); + + await expect(provider.getOpenOrders()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + + it('builds a CAIP account id from the L1 address', async () => { + const { provider } = buildProvider(); + expect(await provider.getCurrentAccountId()).toBe( + 'eip155:300:0x8D7f03FdE1A626223364E592740a233b72395235', + ); + }); + }); + + describe('getMarginModeLock', () => { + it('reports cross when an open position omits its margin mode', async () => { + const { provider } = buildProvider(); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'lighter', + marginMode: 'cross', + reason: 'position', + }); + }); + + it('reports isolated when the open position is isolated', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], marginMode: 1 }], + }, + ], + }); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'locked', + providerId: 'lighter', + marginMode: 'isolated', + reason: 'position', + }); + }); + + it.each([ + ['no position row', 'ETH', ACCOUNT.positions], + [ + 'a zero-size position', + 'BTC', + [{ ...ACCOUNT.positions[0], position: '0' }], + ], + ])('reports unlocked for %s', async (_case, symbol, positions) => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions }], + }); + + const lock = await provider.getMarginModeLock({ symbol }); + + expect(lock).toStrictEqual({ status: 'unlocked', providerId: 'lighter' }); + }); + + it.each([ + ['no account', { code: 200, accounts: [] }], + [ + 'no positions array', + { code: 200, accounts: [{ ...ACCOUNT, positions: undefined }] }, + ], + ])( + 'reports unavailable when the account response has %s', + async (_case, response) => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue(response); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_unavailable', + }); + }, + ); + + it('reports unavailable when the account read fails', async () => { + const infra = createMockInfrastructure(); + const logSpy = jest.spyOn(infra.debugLogger, 'log'); + const { provider, clientInstance } = buildProvider({ + platformDependencies: infra, + }); + clientInstance.getAccountByIndex.mockRejectedValue(new Error('down')); + + const lock = await provider.getMarginModeLock({ symbol: 'BTC' }); + + expect(lock).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_unavailable', + }); + expect(logSpy).toHaveBeenCalledWith( + '[LighterProvider] getMarginModeLock unavailable', + { symbol: 'BTC', error: 'down' }, + ); + }); + }); + + describe('active spot trigger rejection', () => { + const spotMarket = { + ...BTC_MARKET, + symbol: 'UNI/USDC', + marketId: 2048, + marketType: 'spot', + status: 'active', + }; + + it('withholds native trigger capabilities for active spot metadata', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockResolvedValue([spotMarket]); + expect( + await provider.getOrderCapabilities({ symbol: spotMarket.symbol }), + ).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'order_market_unsupported', + }); + expect(calls).toStrictEqual([]); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + await provider.disconnect(); + }); + + it.each(['market', 'limit'] as const)( + 'integration refuses attached %s protection on active spot metadata before setup', + async (orderType) => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockResolvedValue([spotMarket]); + const params: OrderParams = { + symbol: spotMarket.symbol, + isBuy: true, + size: '0.001', + orderType, + ...(orderType === 'limit' ? { price: '95100' } : {}), + takeProfitPrice: '110000', + }; + const validation = await provider.validateOrder(params); + const placement = await provider.placeOrder(params); + expect(validation).toMatchObject({ + isValid: false, + error: expect.stringContaining('active perpetual market') as string, + }); + expect(placement).toMatchObject({ + success: false, + error: expect.stringContaining('active perpetual market') as string, + }); + expect(calls).toStrictEqual([]); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + await provider.disconnect(); + }, + ); + + it('integration rejects spot Scale transport consistently with its unavailable sizing preview', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockResolvedValue([spotMarket]); + const params: OrderParams = { + symbol: spotMarket.symbol, + isBuy: true, + size: '0.001', + orderType: 'scale', + scaleMinPrice: '90000', + scaleMaxPrice: '100000', + scaleNumOrders: 2, + }; + expect( + await provider.getScalePriceLadder({ + symbol: spotMarket.symbol, + minPrice: 90000, + maxPrice: 100000, + count: 2, + }), + ).toMatchObject({ status: 'unavailable' }); + expect(await provider.validateOrder(params)).toMatchObject({ + isValid: false, + error: expect.stringContaining('active perpetual market') as string, + }); + expect(await provider.placeOrder(params)).toMatchObject({ + success: false, + error: expect.stringContaining('active perpetual market') as string, + }); + expect(calls).toStrictEqual([]); + expect(clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + await provider.disconnect(); + }); + + describe.each([ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ] as const)('%s', (orderType) => { + const params: OrderParams = { + symbol: spotMarket.symbol, + isBuy: true, + size: '0.001', + orderType, + triggerPrice: '95000', + price: '95100', + }; + + it('rejects validation before signer setup for active spot metadata', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockResolvedValue([spotMarket]); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...spotMarket, + lastTradePrice: 100000, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], + }); + const validation = await provider.validateOrder(params); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('active perpetual market'); + expect(calls).toStrictEqual([]); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + await provider.disconnect(); + }); + + it('rejects placement before signer setup for active spot metadata', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockResolvedValue([spotMarket]); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...spotMarket, + lastTradePrice: 100000, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], + }); + const result = await provider.placeOrder(params); + expect(result.success).toBe(false); + expect(result.error).toContain('active perpetual market'); + expect(calls).toStrictEqual([]); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + await provider.disconnect(); + }); + }); + }); + + describe('standalone trigger orders', () => { + describe.each([ + ['stop_market', 2, false], + ['stop_limit', 3, true], + ['take_profit_market', 4, false], + ['take_profit_limit', 5, true], + ] as const)('%s', (orderType, wireType, isLimit) => { + it.each([ + [true, false], + [false, false], + [true, true], + [false, true], + ])( + 'preserves buy=%s and reduceOnly=%s in the native wire intent', + async (isBuy, reduceOnly) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy, + reduceOnly, + size: '0.001', + orderType, + triggerPrice: '95000', + maxSlippageBps: 100, + ...(isLimit && { price: '95100' }), + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation).toStrictEqual({ isValid: true }); + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', + ); + const marketExecutionPrice = isBuy ? '959500' : '940500'; + expect(orderCall?.params.slice(3, 11)).toStrictEqual([ + '100', + isLimit ? '951000' : marketExecutionPrice, + isBuy ? 0 : 1, + wireType, + isLimit ? 1 : 0, + reduceOnly ? 1 : 0, + '950000', + -1, + ]); + }, + ); + }); + + it.each([ + undefined, + '', + '0', + '-1', + 'NaN', + 'Infinity', + '90000USD', + '0.01', + '0.11', + '429496729.6', + ])( + 'rejects unrepresentable trigger price %s before signer setup', + async (triggerPrice) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice, + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation.isValid).toBe(false); + expect(result.success).toBe(false); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }, + ); + + it.each(['stop_limit', 'take_profit_limit'] as const)( + 'rejects missing %s execution price before signer setup', + async (orderType) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType, + triggerPrice: '95000', + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation.isValid).toBe(false); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }, + ); + + it.each([NaN, Infinity, -1, 10000])( + 'rejects trigger-market slippage %s before signer setup', + async (maxSlippageBps) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice: '95000', + maxSlippageBps, + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation.isValid).toBe(false); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }, + ); + + it('reports native triggers, durable Scale and pending attached lifecycle verification for a known active market', async () => { + const { provider, calls } = buildProvider(); + + const capabilities = await provider.getOrderCapabilities({ + symbol: 'BTC', + }); + + expect(capabilities).toStrictEqual({ + status: 'ready', + providerId: 'lighter', + supportedStrategies: ['scale'], + supportedMarginModes: ['cross', 'isolated'], + attachedTpsl: { + submission: 'native-oto-otoco', + childCoverage: 'venue-native-zero-size', + partialSizes: false, + lifecycleVerification: 'pending', + cancellation: 'explicit-exact-owned-orders', + }, + positionTpsl: { + supportsExpectedPosition: true, + childOrderIds: 'request-correlated', + takeProfitOrderType: 'take_profit_market', + stopLossOrderType: 'stop_market', + defaultCoverage: 'position-snapshot', + partialCoverage: { + single: true, + pair: 'equal-quantity-oco', + supportedPairs: ['equal-quantity-oco', 'independent'], + replacement: 'cancel-before-create', + recovery: 'explicit-current-position-intent', + }, + }, + supportedTriggerOrderTypes: [ + 'stop_market', + 'stop_limit', + 'take_profit_market', + 'take_profit_limit', + ], + }); + expect(calls).toHaveLength(0); + }); + + it('refreshes cached capability metadata before approving an inactive market', async () => { + const { provider, clientInstance, calls } = buildProvider(); + expect( + (await provider.getOrderCapabilities({ symbol: 'BTC' })).status, + ).toBe('ready'); + clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, status: 'inactive' }, + ]); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice: '95000', + }; + + const capabilities = await provider.getOrderCapabilities({ + symbol: 'BTC', + }); + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(capabilities).toMatchObject({ + status: 'unavailable', + reason: 'order_market_unsupported', + }); + expect(validation.isValid).toBe(false); + expect(result.success).toBe(false); + expect(calls).toHaveLength(0); + }); + + it.each(['', ' '])( + 'refuses the empty capability symbol %j without metadata or signer work', + async (symbol) => { + const { provider, clientInstance, calls } = buildProvider(); + + const result = await provider.getOrderCapabilities({ symbol }); + + expect(result).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'invalid_symbol', + }); + expect(clientInstance.getOrderBooks).not.toHaveBeenCalled(); + expect(calls).toHaveLength(0); + }, + ); + + it.each(['market', 'limit'] as const)( + 'rejects stray triggerPrice on a basic %s before signer work', + async (orderType) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType, + price: '95100', + triggerPrice: '95000', + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation).toStrictEqual({ + isValid: false, + error: 'ORDER_TRIGGER_PRICE_NOT_SUPPORTED', + }); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }, + ); + + it('does not reuse an old active snapshot when native metadata refresh fails', async () => { + const { provider, clientInstance, calls } = buildProvider(); + expect( + (await provider.getOrderCapabilities({ symbol: 'BTC' })).status, + ).toBe('ready'); + clientInstance.getOrderBooks.mockRejectedValue( + new Error('Venue metadata offline'), + ); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice: '95000', + }; + + const capability = await provider.getOrderCapabilities({ symbol: 'BTC' }); + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(capability).toMatchObject({ + status: 'unavailable', + reason: 'provider_unavailable', + }); + expect(validation).toStrictEqual({ + isValid: false, + error: 'Venue metadata offline', + }); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }); + + it.each(['GTC', 'IOC', 'ALO'] as const)( + 'rejects a caller-selected %s time-in-force on a standalone trigger', + async (timeInForce) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_limit', + price: '95100', + triggerPrice: '95000', + timeInForce, + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation).toStrictEqual({ + isValid: false, + error: 'ORDER_TIME_IN_FORCE_NOT_SUPPORTED', + }); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }, + ); + + it.each([ + { takeProfitPrice: '' }, + { stopLossPrice: '90000' }, + { takeProfitSize: '0.001' }, + { tpslLinkage: 'position' as const }, + { twapDuration: 5 }, + ])('rejects extra trigger intent %j before signer setup', async (extra) => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice: '95000', + ...extra, + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation.isValid).toBe(false); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }); + + it('sizes a trigger-market USD order at its trigger without a live-price slippage check', async () => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: 'NaN', + usdAmount: '95', + orderType: 'stop_market', + triggerPrice: '95000', + maxSlippageBps: 100, + priceAtCalculation: 100000, + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation).toStrictEqual({ isValid: true }); + expect(result.success).toBe(true); + expect( + calls.find((call) => call.function === '_signCreateOrder')?.params[3], + ).toBe('100'); + }); + + it('enforces the maker minimum for a trigger limit before signer setup', async () => { + const { provider, calls } = buildProvider(); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.0001', + orderType: 'stop_limit', + triggerPrice: '95000', + price: '95100', + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('below the Lighter minimum'); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }); + + it.each(['stop_limit', 'take_profit_limit'] as const)( + 'never enlarges a below-minimum %s even when it currently closes the full position', + async (orderType) => { + const { provider, calls, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.0001' }], + }, + ], + }); + const params: OrderParams = { + symbol: 'BTC', + isBuy: false, + size: '0.0001', + orderType, + triggerPrice: '95000', + price: '95100', + reduceOnly: true, + isFullClose: true, + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('below the Lighter minimum'); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }, + ); + + it('reports metadata read failures as unavailable capabilities', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockRejectedValue( + new Error('Venue offline'), + ); + + const capabilities = await provider.getOrderCapabilities({ + symbol: 'BTC', + }); + + expect(capabilities).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_unavailable', + }); + expect(calls).toHaveLength(0); + }); + + it('refuses native trigger placement on an inactive market', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, status: 'inactive' }, + ]); + const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice: '95000', + }; + + const capabilities = await provider.getOrderCapabilities({ + symbol: 'BTC', + }); + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(capabilities).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'order_market_unsupported', + }); + expect(validation.isValid).toBe(false); + expect(result.error).toBe(validation.error); + expect(calls).toHaveLength(0); + }); + + it('aborts a trigger intent after the wallet changes during metadata preflight', async () => { + const { provider, clientInstance, calls, getUserAddressMock } = + buildProvider(); + let releaseMetadata = (): void => undefined; + const metadataGate = new Promise((resolve) => { + releaseMetadata = resolve; + }); + clientInstance.getOrderBooks.mockImplementation(async () => { + await metadataGate; + return [BTC_MARKET]; + }); + const placement = provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'stop_market', + triggerPrice: '95000', + }); + expect(clientInstance.getOrderBooks).toHaveBeenCalled(); + + getUserAddressMock.mockReturnValue(`0x${'b'.repeat(40)}`); + releaseMetadata(); + const result = await placement; + + expect(result.success).toBe(false); + expect(calls).toHaveLength(0); + }); + + it('returns unavailable capabilities after provider teardown', async () => { + const { provider } = buildProvider(); + await provider.initialize(); + + await provider.disconnect(); + const capabilities = await provider.getOrderCapabilities({ + symbol: 'BTC', + }); + + expect(capabilities).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'provider_unavailable', + }); + }); + + it('fences capabilities when disconnect occurs after metadata resolves', async () => { + const { provider } = buildProvider(); + const initialize = provider.initialize.bind(provider); + jest.spyOn(provider, 'initialize').mockImplementation(async () => { + const result = await initialize(); + // Queue teardown between #ensureMarkets resolution and its caller. + queueMicrotask(() => { + provider.disconnect().catch(() => undefined); + }); + return result; + }); + expect( + await provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ + status: 'unavailable', + reason: 'provider_unavailable', + }); + }); + + it('returns unavailable capabilities for a missing market', async () => { + const { provider, calls } = buildProvider(); + + const capabilities = await provider.getOrderCapabilities({ + symbol: 'NOPE', + }); + + expect(capabilities).toStrictEqual({ + status: 'unavailable', + providerId: 'lighter', + reason: 'market_not_found', + }); + expect(calls).toHaveLength(0); + }); + }); + + describe('placeOrder', () => { + it.each(['invalid', ''] as const)( + 'rejects malformed explicit mode %s before signing', + async (marginMode) => { + const { provider, calls } = buildProvider(); + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit' as const, + price: '90000', + leverage: 5, + marginMode: marginMode as OrderParams['marginMode'], + }; + + const validation = await provider.validateOrder(params); + const result = await provider.placeOrder(params); + + expect(validation).toStrictEqual({ + isValid: false, + error: 'ORDER_MARGIN_MODE_INVALID', + }); + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_MARGIN_MODE_INVALID'); + expect(calls).toHaveLength(0); + }, + ); + + 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('does not claim or send an isolated-margin update when leverage is omitted', async () => { + const { provider, calls } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }); + + expect(result.success).toBe(true); + expect( + calls.filter((call) => call.function === '_signUpdateLeverage'), + ).toHaveLength(0); + }); + + 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 partial 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', + takeProfitSize: '0.0005', + }); + expect(withTpsl.success).toBe(false); + expect(withTpsl.error).toContain('explicit child sizes'); + }); + + it('allows a below-maker-minimum market partial close without bumping it', async () => { + 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(true); + expect( + calls.find((call) => call.function === '_signCreateOrder')?.params[3], + ).toBe('1'); + }); + + it('does not apply maker minimums to a dust market full close', 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', + ); + expect(orderCall?.params[3]).toBe('1'); + }); + + 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'); + }); + + describe('explicit Lighter margin selection', () => { + const request: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, + marginMode: 'cross', + }; + + it('reports both native modes for an active perpetual market', async () => { + const { provider } = buildProvider(); + expect( + await provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ + supportedMarginModes: ['cross', 'isolated'], + }); + }); + + it.each(['cross', 'isolated'] as const)( + 'validates a flat %s selection without signer calls', + async (marginMode) => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [] }], + }); + expect( + await provider.validateOrder({ ...request, marginMode }), + ).toStrictEqual({ isValid: true }); + expect(calls).toHaveLength(0); + }, + ); + + it('refuses changing the authoritative open position mode before signing', async () => { + const { provider, calls } = buildProvider(); + const result = await provider.placeOrder({ + ...request, + marginMode: 'isolated', + }); + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_MARGIN_MODE_POSITION_OPEN, + }); + expect(calls).toHaveLength(0); + }); + + it('refuses changing the authoritative resting order mode before signing', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + totalOrderCount: 1, + positions: [ + { + ...ACCOUNT.positions[0], + position: '0', + openOrderCount: 1, + marginMode: 1, + }, + ], + }, + ], + }); + expect(await provider.placeOrder(request)).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_MARGIN_MODE_ORDER_OPEN, + }); + expect(calls).toHaveLength(0); + }); + + it('keeps an accepted mode update from placing exposure before exact execution proof', async () => { + const { provider, calls, clientInstance } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [] }], + }); + const result = await provider.placeOrder(request); + expect(result).toMatchObject({ success: false }); + expect(result.error).toContain('margin mode update remains unresolved'); + expect( + calls.filter((call) => call.function === '_signUpdateLeverage'), + ).toHaveLength(1); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }); + + it.each(['cross', 'isolated'] as const)( + 'waits for pending %s mode execution before submitting exposure', + async (marginMode) => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + const mode = marginMode === 'cross' ? 0 : 1; + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], position: '0', marginMode: mode }, + ], + }, + ], + }); + let reads = 0; + clientInstance.getTx.mockImplementation(async (hash: string) => { + reads += 1; + const call = calls.find( + (entry) => entry.function === '_signUpdateLeverage', + ); + return { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: call?.params.at(-1), + status: reads === 1 ? 1 : 2, + }; + }); + expect( + await provider.placeOrder({ ...request, marginMode }), + ).toMatchObject({ success: true }); + expect( + calls.find((call) => call.function === '_signUpdateLeverage') + ?.params[3], + ).toBe(mode); + expect(clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect( + calls.filter((call) => call.function === '_signUpdateLeverage'), + ).toHaveLength(1); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + }, + ); + + it('requires selected mode visibility after the exact mode transaction executes', async () => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], position: '0', marginMode: 1 }, + ], + }, + ], + }); + clientInstance.getTx.mockImplementation(async (hash: string) => { + const call = calls.find( + (entry) => entry.function === '_signUpdateLeverage', + ); + return { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: call?.params.at(-1), + status: 2, + }; + }); + const result = await provider.placeOrder(request); + expect(result.success).toBe(false); + expect(result.error).toContain('selected margin mode'); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }); + + it('retires a proven-unsent attached group when the selected mode changes during signing', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const account = { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], marginMode: 0 }], + }; + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [account], + }); + built.clientInstance.getTx.mockImplementation(async (hash: string) => { + const call = built.calls.find( + (entry) => entry.function === '_signUpdateLeverage', + ); + return { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: call?.params.at(-1), + status: 2, + }; + }); + const execute = jest.spyOn(built.bridge, 'execute'); + const sign = execute.getMockImplementation(); + if (!sign) { + throw new Error('Missing signer fixture'); + } + execute.mockImplementation(async (call: LighterWasmCall) => { + const result = await sign(call); + if (call.function === '_signCreateGroupedOrders') { + account.positions[0].marginMode = 1; + } + return result; + }); + const result = await built.provider.placeOrder({ + ...request, + takeProfitPrice: '110000', + }); + expect(result.success).toBe(false); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect(result.attachedOrderGroup).toMatchObject({ + submission: 'canceled', + }); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); + }); + + it('locks native pending orders even before a child rests', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + pendingOrderCount: 1, + positions: [ + { + ...ACCOUNT.positions[0], + position: '0', + pendingOrderCount: 1, + marginMode: 1, + }, + ], + }, + ], + }); + expect(await provider.placeOrder(request)).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_MARGIN_MODE_ORDER_OPEN, + }); + expect(calls).toHaveLength(0); + }); + + it('requires explicit leverage rather than inventing the mode update IMF', async () => { + const { provider, calls } = buildProvider(); + expect( + await provider.validateOrder({ ...request, leverage: undefined }), + ).toMatchObject({ + isValid: false, + error: PERPS_ERROR_CODES.ORDER_LEVERAGE_INVALID, + }); + expect(calls).toHaveLength(0); + }); + + it('does not treat unavailable positions as a flat account', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: undefined }], + }); + expect(await provider.placeOrder(request)).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, + }); + expect(calls).toHaveLength(0); + }); + }); + + 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< + Promise>, + [LighterWasmCall] + > + ).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('bounded native TWAP lifecycle probe', () => { + it('refuses enabling probe transport on mainnet', () => { + expect(() => + buildProvider({ nativeTwapTestnetProbe: true, isTestnet: false }), + ).toThrow('testnet-only'); + }); + + it('rejects missing native TWAP duration before market or account reads', async () => { + const { provider, calls, clientInstance } = buildProvider({ + nativeTwapTestnetProbe: true, + }); + const result = await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.00020', + leverage: 1, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('duration'); + expect(clientInstance.getOrderBookDetails).not.toHaveBeenCalled(); + expect(clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + expect(calls).toHaveLength(0); + }); + + it('checks native TWAP minimum notional against the lower protected bound before signing', async () => { + const { provider, calls, clientInstance } = buildProvider({ + nativeTwapTestnetProbe: true, + }); + clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, minQuoteAmount: '18' }, + ]); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { ...BTC_MARKET, lastTradePrice: 90000, minQuoteAmount: '18' }, + ], + }); + const result = await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: false, + size: '0.00020', + leverage: 1, + twapDuration: 1, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('parent minimums'); + expect(calls).toHaveLength(0); + }); + + it.each([ + { slippage: undefined, priceAtCalculation: undefined }, + { slippage: 0.005, priceAtCalculation: undefined }, + { slippage: 0.01, priceAtCalculation: 90000 }, + ])( + 'prepares fresh native TWAP reference and duration after readiness with slippage %s', + async ({ slippage, priceAtCalculation }) => { + let clock = Date.now(); + let reference = 90000; + const now = jest.spyOn(Date, 'now').mockImplementation(() => clock); + const { provider, calls, clientInstance } = buildProvider({ + nativeTwapTestnetProbe: true, + registeredKey: '9c'.repeat(40), + }); + clientInstance.getOrderBookDetails.mockImplementation(async () => ({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: reference, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], + })); + clientInstance.getAccountByIndex.mockImplementation(async () => { + clock += 20000; + reference = 88000; + return { + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: ACCOUNT.positions.map((position) => ({ + ...position, + initialMarginFraction: '100', + })), + }, + ], + }; + }); + try { + await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.00020', + leverage: 1, + twapDuration: 1, + ...(slippage === undefined ? {} : { slippage }), + ...(priceAtCalculation === undefined ? {} : { priceAtCalculation }), + }); + const native = calls.find( + (call) => call.function === '_signCreateOrder', + ); + expect(native?.params[10]).toBe( + priceAtCalculation === undefined ? clock + 60000 : undefined, + ); + expect(native?.params[4]).toBe( + priceAtCalculation === undefined + ? String(Math.round(88000 * (1 + (slippage ?? 0.01)) * 10)) + : undefined, + ); + } finally { + now.mockRestore(); + } + }, + ); + + it('rejects over-budget intent before signer setup', async () => { + const { provider, calls } = buildProvider({ + nativeTwapTestnetProbe: true, + }); + const result = await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.001', + leverage: 1, + twapDuration: 1, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('20 USD'); + expect(calls).toHaveLength(0); + }); + + it('retains schedule ownership across disconnect and reads it after restart without financial replay', async () => { + const disk = new Map(); + const dependencies = createMockInfrastructure(); + jest + .spyOn(dependencies.diskCache, 'getItem') + .mockImplementation(async (key: string) => disk.get(key) ?? null); + jest + .spyOn(dependencies.diskCache, 'setItem') + .mockImplementation(async (key: string, value: string) => { + disk.set(key, value); + }); + const first = buildProvider({ + nativeTwapTestnetProbe: true, + platformDependencies: dependencies, + registeredKey: '9c'.repeat(40), + }); + first.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], + }); + first.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: ACCOUNT.positions.map((position) => ({ + ...position, + initialMarginFraction: '100', + })), + }, + ], + }); + await first.provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.00020', + leverage: 1, + twapDuration: 1, + }); + const before = [...disk.entries()].filter(([key]) => + key.startsWith('lighterNativeTwap:'), + ); + await first.provider.disconnect(); + expect( + [...disk.entries()].filter(([key]) => + key.startsWith('lighterNativeTwap:'), + ), + ).toStrictEqual(before); + const restarted = buildProvider({ + platformDependencies: dependencies, + registeredKey: '9c'.repeat(40), + }); + const observations = await restarted.provider.getNativeTwapObservations(); + expect(observations).toHaveLength(1); + expect(observations[0].record.terminalConfirmed).toBe(false); + expect( + restarted.calls.filter( + (call) => + call.function === '_signCreateOrder' || + call.function === '_signCancelOrder' || + call.function === '_signChangePubKey', + ), + ).toHaveLength(0); + }); + + it('requires observed existing 1x leverage before signer setup', async () => { + const { provider, calls, clientInstance } = buildProvider({ + nativeTwapTestnetProbe: true, + }); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], + }); + const result = await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.00020', + leverage: 1, + twapDuration: 1, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('existing 1x'); + expect(calls).toHaveLength(0); + }); + + it('persists the exact schedule before native type6 send and retains unresolved parent visibility', async () => { + const disk = new Map(); + const dependencies = createMockInfrastructure(); + jest + .spyOn(dependencies.diskCache, 'getItem') + .mockImplementation(async (key: string) => disk.get(key) ?? null); + jest + .spyOn(dependencies.diskCache, 'setItem') + .mockImplementation(async (key: string, value: string) => { + disk.set(key, value); + }); + const { provider, calls, clientInstance } = buildProvider({ + nativeTwapTestnetProbe: true, + platformDependencies: dependencies, + registeredKey: '9c'.repeat(40), + }); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: ACCOUNT.positions.map((position) => ({ + ...position, + initialMarginFraction: '100', + })), + }, + ], + }); + const persistedAtSend: boolean[] = []; + clientInstance.sendTx.mockImplementation(async (kind: number) => { + if (kind === LIGHTER_TX_TYPE_CREATE_ORDER) { + persistedAtSend.push( + [...disk.keys()].some((key) => + key.startsWith('lighterNativeTwap:'), + ), + ); + } + return { code: 200, txHash: 'accepted' }; + }); + const result = await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.00020', + leverage: 1, + twapDuration: 1, + }); + const native = calls.find((call) => call.function === '_signCreateOrder'); + expect(persistedAtSend).toStrictEqual([true]); + expect(native?.params[6]).toBe(6); + expect(native?.params[7]).toBe(1); + expect(native?.params[9]).toBe('0'); + expect(result.success).toBe(false); + expect( + [...disk.keys()].some((key) => key.startsWith('lighterNativeTwap:')), + ).toBe(true); + const observations = await provider.getNativeTwapObservations(); + expect(observations).toHaveLength(1); + expect(observations[0].record.terminalConfirmed).toBe(false); + expect(observations[0].issue).toContain('parent unavailable'); + }); + }); + + describe('native TWAP exact-parent provider lifecycle', () => { + it.each([ + { historyUnavailable: false, trailingFailure: false }, + { historyUnavailable: true, trailingFailure: false }, + { historyUnavailable: false, trailingFailure: true }, + ])( + 'returns the exact parent receipt and cancels with history unavailable=%s without duplicate dispatch', + async ({ historyUnavailable, trailingFailure }) => { + const { provider, calls, clientInstance } = buildProvider({ + nativeTwapTestnetProbe: true, + registeredKey: '9c'.repeat(40), + }); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: ACCOUNT.positions.map((position) => ({ + ...position, + initialMarginFraction: '100', + })), + }, + ], + }); + let status = 'in-progress'; + const parentRows = (): LighterApiOrder[] => { + const call = calls.find( + (entry) => entry.function === '_signCreateOrder', + ); + if (!call) { + return []; + } + const values = call.params; + return [ + { + orderIndex: 9001, + orderId: '9001', + clientOrderIndex: values[2], + clientOrderId: String(values[2]), + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.00020', + remainingBaseAmount: '0.00020', + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: String(Number(values[4]) / 10), + isAsk: false, + type: 'twap', + timeInForce: 'good-till-time', + reduceOnly: false, + status, + orderExpiry: values[10], + timestamp: Math.floor((values[10] - 60_000) / 1000), + nonce: values[11], + }, + ]; + }; + clientInstance.getOrdersByClientIds.mockImplementation(async () => ({ + code: 200, + orders: parentRows(), + })); + clientInstance.getActiveOrders.mockImplementation(async () => ({ + code: 200, + orders: status === 'in-progress' ? parentRows() : [], + })); + clientInstance.getInactiveOrders.mockImplementation(async () => ({ + code: 200, + orders: status === 'canceled' ? parentRows() : [], + })); + clientInstance.getTx.mockImplementation(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: hash.startsWith('aaaa') ? 42 : 43, + status: 2, + })); + clientInstance.sendTx.mockImplementation(async (kind: number) => { + if (kind === LIGHTER_TX_TYPE_CANCEL_ORDER) { + status = 'canceled'; + } + return { code: 200, txHash: 'accepted' }; + }); + const placed = await provider.placeOrder({ + symbol: 'BTC', + orderType: 'twap', + isBuy: true, + size: '0.00020', + leverage: 1, + twapDuration: 1, + }); + expect(placed).toMatchObject({ success: true, orderId: '9001' }); + if (historyUnavailable) { + clientInstance.getInactiveOrders.mockRejectedValue( + new Error('history unavailable'), + ); + } + if (trailingFailure) { + jest + .spyOn(provider, 'getNativeTwapObservations') + .mockRejectedValueOnce(new Error('after cancel read failed')); + } + const cancellation = await provider.cancelOrder({ + symbol: 'BTC', + orderType: 'twap', + orderId: '9001', + }); + expect(cancellation.success).toBe(false); + expect(cancellation.error).toContain( + trailingFailure + ? 'observation failed: after cancel read failed' + : 'termination remains unverified', + ); + const observations = await provider.getNativeTwapObservations(); + expect(observations[0]).toMatchObject( + historyUnavailable + ? { issue: 'history unavailable' } + : { observation: { terminalObserved: true } }, + ); + expect(observations[0].record.terminalConfirmed).toBe(false); + const secondCancellation = await provider.cancelOrder({ + symbol: 'BTC', + orderType: 'twap', + orderId: '9001', + }); + expect(secondCancellation).toMatchObject({ + success: false, + error: 'Lighter TWAP cancellation remains unresolved', + }); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + }, + ); + }); + + describe('native TWAP unavailable lifecycle', () => { + it('does not report an empty schedule inventory without authoritative reads', async () => { + const { provider, clientInstance, calls } = buildProvider(); + await expect(provider.getTwapOrders()).rejects.toThrow( + 'TWAP lifecycle is unavailable', + ); + expect(calls).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('keeps native TWAP absent from advertised strategies', async () => { + const { provider } = buildProvider(); + const capabilities = await provider.getOrderCapabilities({ + symbol: 'BTC', + }); + expect(capabilities).toMatchObject({ + status: 'ready', + supportedStrategies: ['scale'], + }); + }); + + it('rejects native placement before signer setup or submission', async () => { + const { provider, clientInstance, calls } = buildProvider(); + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'twap', + twapDuration: 5, + twapRandomize: false, + }); + expect(result.success).toBe(false); + expect(calls).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + }); + + describe('cancelOrder', () => { + it('refuses native TWAP cancellation before signer setup or submission', async () => { + const { provider, clientInstance, calls } = buildProvider(); + const result = await provider.cancelOrder({ + orderId: '555', + symbol: 'BTC', + orderType: 'twap', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('TWAP lifecycle is unavailable'); + expect(calls).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + 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('does not log every price-stream frame', async () => { + const infra = createMockInfrastructure(); + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + platformDependencies: infra, + }); + const unsubscribe = 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') }, + }); + socket.receive({ + type: 'update/market_stats', + market_stats: { '1': wsStat('BTC', 1, '63001.5') }, + }); + + expect(infra.debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('[LighterProvider] price stream cycle='), + ); + 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, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: 'a'.repeat(80), + }); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + { + ...BTC_MARKET, + symbol: 'SOL', + marketId: 2, + lastTradePrice: 100, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }); + 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('applies position snapshots and deltas transactionally', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: 'a'.repeat(80), + }); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 100000, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + { + ...BTC_MARKET, + symbol: 'SOL', + marketId: 2, + lastTradePrice: 100, + minInitialMarginFraction: 400, + maintenanceMarginFraction: 240, + }, + ], + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPositions({ callback }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + const position = { + 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: '40000', + }; + socket.receive({ + type: 'subscribed/account_all_positions', + positions: { '1': position }, + }); + expect(callback).toHaveBeenLastCalledWith([ + expect.objectContaining({ symbol: 'BTC', size: '0.5' }), + ]); + callback.mockClear(); + + socket.receive({ + type: 'subscribed/account_all_positions', + positions: { + '2': { ...position, market_id: 2, symbol: 'SOL', position: '1' }, + '3': { + ...position, + market_id: 3, + symbol: 'ETH', + position: '0.2oops', + }, + }, + }); + expect(callback).not.toHaveBeenCalled(); + + socket.receive({ + type: 'update/account_all_positions', + positions: { + '2': { ...position, market_id: 2, symbol: 'SOL', position: '1' }, + }, + }); + expect(callback).toHaveBeenLastCalledWith([ + expect.objectContaining({ symbol: 'BTC', size: '0.5' }), + expect.objectContaining({ symbol: 'SOL', size: '1' }), + ]); + + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToPositions({ callback: late }); + expect(late).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', size: '0.5' }), + expect.objectContaining({ symbol: 'SOL', size: '1' }), + ]); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it('preserves parked protection warnings from another key slot of the same wallet', async () => { + const infra = createMockInfrastructure(); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const otherWalletKey = '0xbbbb:28:19:ETH'; + const storage = new Map([ + [ + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey, otherWalletKey]), + ], + [ + `lighterTpslManual:testnet:${settlementKey}`, + JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'Protection needs review', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: ['777'], + operationId: 'public-test-operation', + recordedAt: 5, + }), + ], + ]); + const getItem = jest.spyOn(infra.diskCache, 'getItem'); + const removeItem = jest.spyOn(infra.diskCache, 'removeItem'); + getItem.mockImplementation(async (key) => storage.get(key) ?? null); + const { provider } = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + + const pending = await provider.getPendingManualRecoveries(); + + expect(pending.map((entry) => entry.settlementKey)).toStrictEqual([ + settlementKey, + ]); + expect(getItem).not.toHaveBeenCalledWith( + `lighterTpslManual:testnet:${otherWalletKey}`, + ); + expect(removeItem).not.toHaveBeenCalled(); + expect(pending[0].actionNeeded).toContain( + 'Initialize the wallet trading key', + ); + expect(pending[0].actionNeeded).not.toContain('previous trading key'); + + await provider.getOrders(); + const initializedPending = await provider.getPendingManualRecoveries(); + expect( + initializedPending.map((entry) => entry.settlementKey), + ).toStrictEqual([settlementKey]); + expect(initializedPending[0].actionNeeded).toContain( + 'current trading key', + ); + expect(initializedPending[0].actionNeeded).toContain( + 'explicit TP/SL update or removal', + ); + expect(removeItem).not.toHaveBeenCalled(); + }); + + it('replays an authenticated empty orders snapshot to late subscribers', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrders({ callback: jest.fn() }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ + type: 'subscribed/account_all_orders', + channel: 'account_all_orders:28', + orders: {}, + }); + const late = jest.fn(); + + const unsubscribeLate = provider.subscribeToOrders({ callback: late }); + + expect(late).toHaveBeenCalledWith([]); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it.each([0, true, null, [], 'invalid', { '1': false }])( + 'does not authorize late replay from malformed trades container %j', + async (trades) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + // Positions initialize the shared authenticated trade channel without a fill subscriber. + const unsubscribe = provider.subscribeToPositions({ + callback: jest.fn(), + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ type: 'subscribed/account_all_trades', trades }); + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(late).not.toHaveBeenCalled(); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }, + ); + + it.each([false, true])( + 'invalidates fills replay for null rows after populated=%s snapshot', + async (populated) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrderFills({ + callback: jest.fn(), + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + const trade = { + 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, + taker_position_size_before: '0', + }; + socket.receive({ + type: 'subscribed/account_all_trades', + trades: populated ? { '1': [trade] } : {}, + }); + socket.receive({ + type: 'update/account_all_trades', + trades: { '1': [null] }, + }); + const late = jest.fn(); + const stop = provider.subscribeToOrderFills({ callback: late }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(late).not.toHaveBeenCalled(); + stop(); + unsubscribe(); + await provider.disconnect(); + }, + ); + + it.each([ + ['subscribed', 'venue-added-state'], + ['update', 'venue-added-state'], + ['subscribed', undefined], + ['update', undefined], + ])( + 'revokes empty order replay after %s adapter rejection with status=%s and restores it on a valid snapshot', + async (frame, status) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrders({ callback }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ type: 'subscribed/account_all_orders', orders: {} }); + callback.mockClear(); + socket.receive({ + type: `${frame}/account_all_orders`, + orders: { '1': [{ status }] }, + }); + expect(callback).not.toHaveBeenCalled(); + const late = jest.fn(); + const stop = provider.subscribeToOrders({ callback: late }); + expect(late).not.toHaveBeenCalled(); + socket.receive({ type: 'subscribed/account_all_orders', orders: {} }); + expect(late).toHaveBeenCalledWith([]); + const restored = jest.fn(); + const stopRestored = provider.subscribeToOrders({ callback: restored }); + expect(restored).toHaveBeenCalledWith([]); + stopRestored(); + stop(); + unsubscribe(); + await provider.disconnect(); + }, + ); + + it.each(['none', 'empty', 'populated'])( + 'ignores omitted orders after %s state without extra delivery', + async (state) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrders({ callback }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + if (state !== 'none') { + socket.receive({ + type: 'subscribed/account_all_orders', + orders: + state === 'empty' + ? {} + : { + '1': [ + { + order_index: 555, + client_order_index: 1, + market_index: 1, + owner_account_index: 28, + initial_base_amount: '0.001', + remaining_base_amount: '0.001', + price: '90000', + is_ask: false, + type: 'limit', + time_in_force: 'good-till-time', + reduce_only: 0, + status: 'open', + order_expiry: 0, + timestamp: 1700000000000, + }, + ], + }, + }); + } + const previousCalls = [...callback.mock.calls]; + callback.mockClear(); + socket.receive({ type: 'subscribed/account_all_orders' }); + socket.receive({ type: 'update/account_all_orders' }); + expect(callback).not.toHaveBeenCalled(); + const late = jest.fn(); + const stop = provider.subscribeToOrders({ callback: late }); + expect(late.mock.calls).toStrictEqual(previousCalls); + stop(); + unsubscribe(); + await provider.disconnect(); + }, + ); + + it.each([true, [], { '1': false }, { '1': [null] }])( + 'rejects malformed order containers %j before late replay', + async (orders) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrders({ callback: jest.fn() }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ type: 'subscribed/account_all_orders', orders }); + const late = jest.fn(); + const stop = provider.subscribeToOrders({ callback: late }); + expect(late).not.toHaveBeenCalled(); + stop(); + unsubscribe(); + await provider.disconnect(); + }, + ); + + it.each(['account', 'auth'])( + 'isolates throwing error subscribers on %s setup failure and retries', + async (stage) => { + const built = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + configuredAccountIndex: null, + }); + if (stage === 'account') { + built.clientInstance.getAccountsByL1Address.mockRejectedValue( + new Error('setup failed'), + ); + } else { + built.clientInstance.getApiKeys.mockRejectedValue( + new Error('setup failed'), + ); + } + const stopFirst = built.provider.subscribeToOrders({ + callback: jest.fn(), + onError: () => { + throw new Error('listener failed'); + }, + }); + const sibling = jest.fn(); + const stopSibling = built.provider.subscribeToOrderFills({ + callback: jest.fn(), + onError: sibling, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(sibling).toHaveBeenCalled(); + sibling.mockClear(); + const retry = built.provider.subscribeToOrders({ callback: jest.fn() }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(sibling).toHaveBeenCalled(); + retry(); + stopSibling(); + stopFirst(); + await built.provider.disconnect(); + }, + ); + + it('replays the newest 100 unique fills after a repeated fill and older delta', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrderFills({ + callback: jest.fn(), + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + const trades = Array.from({ length: 105 }, (_, index) => ({ + trade_id: index + 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 + index * 1000, + taker_position_size_before: '0', + })); + socket.receive({ + type: 'subscribed/account_all_trades', + trades: { '1': trades }, + }); + socket.receive({ + type: 'update/account_all_trades', + trades: { + '1': [ + { ...trades[104], price: '91000' }, + { ...trades[0], trade_id: 200, timestamp: 1699999000000 }, + ], + }, + }); + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const replay = late.mock.calls[0][0]; + expect(replay.map((fill) => fill.fillId)).toStrictEqual( + Array.from({ length: 100 }, (_, index) => String(105 - index)), + ); + expect(replay[0].price).toBe('91000'); + expect(late.mock.calls[0][1]).toBe(true); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it('replays closing fills with omitted pnl without losing authenticated history', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const callback = jest.fn(); + const onError = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ callback, onError }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ type: 'subscribed/account_all_trades', trades: {} }); + const trade = { + trade_id: 91650, + 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, + taker_position_size_before: '0.001', + taker_position_sign_changed: true, + }; + + socket.receive({ + type: 'update/account_all_trades', + trades: { '1': [trade] }, + }); + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(onError).not.toHaveBeenCalled(); + expect(late).toHaveBeenCalledWith( + [expect.objectContaining({ fillId: '91650', direction: 'Close Long' })], + true, + ); + expect(late.mock.calls[0][0][0]).not.toHaveProperty('pnl'); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it.each( + ['subscribed', 'update'].flatMap((frameType) => + ['none', 'empty', 'filled'].map((prior) => ({ frameType, prior })), + ), + )( + 'withholds full fill replay after invalid $frameType fills with $prior prior history', + async ({ frameType, prior }) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ callback }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + const trade = { + 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, + taker_position_size_before: '0', + }; + if (prior !== 'none') { + socket.receive({ + type: 'subscribed/account_all_trades', + trades: prior === 'empty' ? {} : { '1': [trade] }, + }); + } + callback.mockClear(); + socket.receive({ + type: `${frameType}/account_all_trades`, + trades: { '1': [{ ...trade, size: 'invalid' }] }, + }); + expect(callback).not.toHaveBeenCalled(); + socket.receive({ + type: 'update/account_all_trades', + trades: { '1': [{ ...trade, trade_id: 2 }] }, + }); + expect(callback).toHaveBeenLastCalledWith( + [expect.objectContaining({ fillId: '2' })], + false, + ); + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(late).not.toHaveBeenCalled(); + // Only a new validated complete snapshot can restore replay authority. + socket.receive({ type: 'subscribed/account_all_trades', trades: {} }); + const restored = jest.fn(); + const unsubscribeRestored = provider.subscribeToOrderFills({ + callback: restored, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(restored).toHaveBeenCalledWith([], true); + unsubscribeRestored(); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }, + ); + + it('does not promote a delta before the initial fills snapshot to full replay', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrderFills({ + callback: jest.fn(), + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ + type: 'update/account_all_trades', + 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, + taker_position_size_before: '0', + }, + ], + }, + }); + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(late).not.toHaveBeenCalled(); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it('does not retain complete fill replay after the wallet session changes', async () => { + const { provider, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrderFills({ + callback: jest.fn(), + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ type: 'subscribed/account_all_trades', trades: {} }); + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState().catch(() => undefined); + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(late).not.toHaveBeenCalled(); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it('reports current-wallet account discovery failures to both trading subscriptions', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + const failure = new Error('Account discovery transport unavailable'); + clientInstance.getAccountsByL1Address.mockRejectedValue(failure); + const onOrderError = jest.fn(); + const onFillError = jest.fn(); + const orders = jest.fn(); + const fills = jest.fn(); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: orders, + onError: onOrderError, + }); + const unsubscribeFills = provider.subscribeToOrderFills({ + callback: fills, + onError: onFillError, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(onOrderError).toHaveBeenCalledWith(failure); + expect(onFillError).toHaveBeenCalledWith(failure); + expect(orders).not.toHaveBeenCalled(); + expect(fills).not.toHaveBeenCalled(); + unsubscribeOrders(); + unsubscribeFills(); + await provider.disconnect(); + }); + + it('does not report an old-wallet account discovery failure to a replacement session', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + const failure = new Error('Old-wallet discovery unavailable'); + let failLookup: (error: Error) => void = () => undefined; + let lookupStarted: () => void = () => undefined; + const started = new Promise((resolveStarted) => { + lookupStarted = resolveStarted; + }); + clientInstance.getAccountsByL1Address.mockImplementationOnce(() => { + lookupStarted(); + return new Promise((_resolve, reject) => { + failLookup = reject; + }); + }); + const onOrderError = jest.fn(); + const onFillError = jest.fn(); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: jest.fn(), + onError: onOrderError, + }); + const unsubscribeFills = provider.subscribeToOrderFills({ + callback: jest.fn(), + onError: onFillError, + }); + await started; + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState().catch(() => undefined); + failLookup(failure); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(onOrderError).not.toHaveBeenCalledWith(failure); + expect(onFillError).not.toHaveBeenCalledWith(failure); + unsubscribeOrders(); + unsubscribeFills(); + await provider.disconnect(); + }); + + it.each( + ['subscribed', 'update'].flatMap((frameType) => + ['none', 'empty', 'filled'].map((prior) => ({ frameType, prior })), + ), + )( + 'ignores omitted $frameType trades with $prior prior history', + async ({ frameType, prior }) => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ callback }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + let unsubscribeLate = (): void => undefined; + try { + const trade = { + 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, + taker_position_size_before: '0', + }; + if (prior !== 'none') { + socket.receive({ + type: 'subscribed/account_all_trades', + trades: prior === 'empty' ? {} : { '1': [trade] }, + }); + } + callback.mockClear(); + socket.receive({ + type: `${frameType}/account_all_trades`, + channel: 'account_all_trades:28', + }); + expect(callback).not.toHaveBeenCalled(); + const late = jest.fn(); + unsubscribeLate = provider.subscribeToOrderFills({ callback: late }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const firstFill: unknown = expect.objectContaining({ fillId: '1' }); + const expectedPrior = + prior === 'none' + ? [] + : [[prior === 'empty' ? [] : [firstFill], true]]; + expect(late.mock.calls).toStrictEqual(expectedPrior); + const secondFill: unknown = expect.objectContaining({ fillId: '2' }); + for (const populated of [false, true]) { + callback.mockClear(); + late.mockClear(); + socket.receive({ + type: 'subscribed/account_all_trades', + trades: populated ? { '1': [{ ...trade, trade_id: 2 }] } : {}, + }); + const expected = populated ? [secondFill] : []; + expect(callback).toHaveBeenCalledWith(expected, true); + expect(late).toHaveBeenCalledWith(expected, true); + } + } finally { + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + } + }, + ); + + it('replays a confirmed empty fills snapshot to late authenticated subscribers', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const unsubscribe = provider.subscribeToOrders({ callback: jest.fn() }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + socket.receive({ + type: 'subscribed/account_all_trades', + channel: 'account_all_trades:28', + trades: {}, + }); + const late = jest.fn(); + + const unsubscribeLate = provider.subscribeToOrderFills({ + callback: late, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(late).toHaveBeenCalledWith([], true); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it('keeps the previous orders snapshot when a new snapshot is malformed', async () => { + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: 'a'.repeat(80), + }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrders({ callback }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + const socket = FakeWebSocket.instances[0]; + socket.open(); + const validOrder = { + order_index: 555, + client_order_index: 1, + market_index: 1, + owner_account_index: 28, + initial_base_amount: '0.001', + remaining_base_amount: '0.001', + price: '90000', + is_ask: false, + type: 'limit', + time_in_force: 'good-till-time', + reduce_only: 0, + status: 'open', + order_expiry: 0, + timestamp: 1700000000000, + }; + socket.receive({ + type: 'subscribed/account_all_orders', + channel: 'account_all_orders:28', + orders: { '1': [validOrder] }, + }); + expect(callback).toHaveBeenLastCalledWith([ + expect.objectContaining({ orderId: '555' }), + ]); + callback.mockClear(); + + socket.receive({ + type: 'subscribed/account_all_orders', + channel: 'account_all_orders:28', + orders: { + '1': [{ ...validOrder, status: 'venue-added-state' }], + }, + }); + expect(callback).not.toHaveBeenCalled(); + + const late = jest.fn(); + const unsubscribeLate = provider.subscribeToOrders({ callback: late }); + expect(late).toHaveBeenCalledWith([ + expect.objectContaining({ orderId: '555' }), + ]); + unsubscribeLate(); + unsubscribe(); + await provider.disconnect(); + }); + + it('drops malformed user_stats numbers without throwing or emitting a partial account state', async () => { + const infra = createMockInfrastructure(); + const { provider } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: 'a'.repeat(80), + platformDependencies: infra, + }); + const accountCallback = jest.fn(); + const unsubscribe = provider.subscribeToAccount({ + callback: accountCallback, + }); + 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(); + accountCallback.mockClear(); + + expect(() => + socket.receive({ + type: 'update/user_stats', + channel: 'user_stats:28', + stats: { + collateral: '10000USD', + portfolio_value: '11000', + leverage: '2', + available_balance: '6000', + margin_usage: '40', + buying_power: '0', + }, + }), + ).not.toThrow(); + expect(accountCallback).not.toHaveBeenCalled(); + expect(infra.debugLogger.log).toHaveBeenCalledWith( + '[LighterProvider] dropped malformed WebSocket frame', + expect.objectContaining({ + error: expect.stringContaining( + 'Invalid Lighter venue data', + ) as string, + }), + ); + + unsubscribe(); + await provider.disconnect(); + }); + + it('does not emit authoritative empty state when account-channel discovery fails', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockRejectedValue( + new Error('Invalid Lighter venue data: malformed account discovery'), + ); + const accountCallback = jest.fn(); + const positionsCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(accountCallback).not.toHaveBeenCalled(); + expect(positionsCallback).not.toHaveBeenCalled(); + expect(ordersCallback).not.toHaveBeenCalled(); + unsubscribeAccount(); + unsubscribePositions(); + unsubscribeOrders(); + await provider.disconnect(); + }); + + it('emits authoritative empty state when the selected wallet has no Lighter account', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockRejectedValue( + new LighterApiError('account not found', 21100), + ); + const accountCallback = jest.fn(); + const positionsCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(accountCallback).toHaveBeenCalledWith( + expect.objectContaining({ + totalBalance: '0', + spendableBalance: '0', + providerId: 'lighter', + }), + ); + expect(positionsCallback).toHaveBeenCalledWith([]); + expect(ordersCallback).toHaveBeenCalledWith([]); + unsubscribeAccount(); + unsubscribePositions(); + unsubscribeOrders(); + await provider.disconnect(); + }); + + it('also settles account subscribers when discovery succeeds with no accounts', async () => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeCtor, + configuredAccountIndex: null, + }); + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [], + }); + const positionsCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(positionsCallback).toHaveBeenCalledWith([]); + expect(ordersCallback).toHaveBeenCalledWith([]); + unsubscribePositions(); + unsubscribeOrders(); + await provider.disconnect(); + }); + + it('reports a same-wallet signer creation failure to orders and fills subscribers', async () => { + const { provider, bridge } = buildProvider({ webSocketCtor: fakeCtor }); + const failure = new Error('Lighter signer setup failed'); + (bridge.createClient as jest.Mock).mockRejectedValue(failure); + const onOrderError = jest.fn(); + const onFillError = jest.fn(); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: jest.fn(), + onError: onOrderError, + }); + const unsubscribeFills = provider.subscribeToOrderFills({ + callback: jest.fn(), + onError: onFillError, + }); + + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(onOrderError).toHaveBeenCalledWith(failure); + expect(onFillError).toHaveBeenCalledWith(failure); + unsubscribeOrders(); + unsubscribeFills(); + await provider.disconnect(); + }); + + it('does not clear orders when authenticated channel setup fails', async () => { + const { provider, bridge } = buildProvider({ + webSocketCtor: fakeCtor, + registeredKey: '9c'.repeat(40), + }); + const mockedExecute = jest.spyOn(bridge, 'execute'); + const realExecute = mockedExecute.getMockImplementation()?.bind(bridge); + if (!realExecute) { + throw new Error('Missing bridge mock implementation'); + } + mockedExecute.mockImplementation(async (call: LighterWasmCall) => { + if (call.function === '_createAuthToken') { + throw new Error( + 'Invalid Lighter venue data: malformed auth-token response', + ); + } + return await realExecute(call); + }); + const ordersCallback = jest.fn(); + const onError = jest.fn(); + const unsubscribe = provider.subscribeToOrders({ + callback: ordersCallback, + onError, + }); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + await new Promise((resolveTick) => setImmediate(resolveTick)); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid Lighter venue data: malformed auth-token response', + }), + ); + expect(ordersCallback).not.toHaveBeenCalledWith([]); + expect(ordersCallback).not.toHaveBeenCalled(); + unsubscribe(); + 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, + }); + expect(provider.getWebSocketConnectionState()).toBe('disconnected'); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPrices({ + symbols: [], + callback, + }); + expect(provider.getWebSocketConnectionState()).toBe('connected'); + 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(); + expect(provider.getWebSocketConnectionState()).toBe('disconnected'); + const callsAfter = clientInstance.getOrderBookDetails.mock.calls.length; + await jest.advanceTimersByTimeAsync(20_000); + expect(clientInstance.getOrderBookDetails.mock.calls).toHaveLength( + callsAfter, + ); + await provider.disconnect(); + expect(provider.getWebSocketConnectionState()).toBe('disconnected'); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('session binding', () => { + it('re-arms one signer reset listener after wallet deselection and reselection', async () => { + const { + provider, + calls, + fireReset, + getUserAddressMock, + resetListenerCount, + } = buildProvider({ registeredKey: '9c'.repeat(40) }); + expect((await provider.isReadyToTrade()).ready).toBe(true); + expect( + calls.filter((call) => call.function === '_createClient'), + ).toHaveLength(1); + + getUserAddressMock.mockImplementation(() => { + throw new Error('no selected wallet account'); + }); + await provider.getAccountState(); + expect(resetListenerCount()).toBe(0); + + getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + await provider.getAccountState(); + expect(resetListenerCount()).toBe(1); + fireReset(); + + expect((await provider.isReadyToTrade()).ready).toBe(true); + expect( + calls.filter((call) => call.function === '_createClient'), + ).toHaveLength(2); + await provider.disconnect(); + expect(resetListenerCount()).toBe(0); + }); + + it('replaces the signer reset listener on account rebind and removes it on disconnect', async () => { + const { + provider, + getUserAddressMock, + resetListenerCount, + resetUnsubscribe, + } = buildProvider(); + expect(resetListenerCount()).toBe(1); + await provider.getAccountState(); + getUserAddressMock.mockReturnValue( + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ); + await provider.getAccountState().catch(() => undefined); + expect(resetUnsubscribe).toHaveBeenCalledTimes(1); + expect(resetListenerCount()).toBe(1); + await provider.disconnect(); + expect(resetUnsubscribe).toHaveBeenCalledTimes(2); + expect(resetListenerCount()).toBe(0); + }); + + 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, l1Address: '0xbbbb' }; + // 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: [number]) => 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, l1Address: '0xbbbb' }; + 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 accountCallback = jest.fn(); + const positionsCallback = jest.fn(); + const ordersCallback = jest.fn(); + const unsubscribePrices = provider.subscribeToPrices({ + symbols: [], + callback: jest.fn(), + }); + const unsubscribeAccount = provider.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribePositions = provider.subscribeToPositions({ + callback: positionsCallback, + }); + const unsubscribeOrders = provider.subscribeToOrders({ + callback: ordersCallback, + }); + 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. + accountCallback.mockClear(); + positionsCallback.mockClear(); + ordersCallback.mockClear(); + getUserAddressMock.mockReturnValue('0xbbbb'); + await provider.getAccountState(); + expect(accountCallback).toHaveBeenCalledWith( + expect.objectContaining({ + totalBalance: '0', + spendableBalance: '0', + providerId: 'lighter', + }), + ); + expect(positionsCallback).toHaveBeenCalledWith([]); + expect(ordersCallback).toHaveBeenCalledWith([]); + 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(); + unsubscribePositions(); + unsubscribeOrders(); + }); + + 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, l1Address: '0xbbbb' }; + 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[1]).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, l1Address: '0xbbbb' }; + 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, l1Address: '0xbbbb' }; + // 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, l1Address: '0xbbbb' }; + 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< + Promise>, + [LighterWasmCall] + > + ).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: ACCOUNT.positions[0].position, + remainingBaseAmount: ACCOUNT.positions[0].position, + 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; + baseAmount: string; + }; + 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: number = 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 0 (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: 2 }); + batch.creates.forEach((create, createIndexInBatch) => { + const orderIndex = nextIndex; + nextIndex += 1; + const row = { + ...buildRawTrigger(orderIndex, create.type, create.triggerPrice), + clientOrderIndex: create.clientOrderIndex, + initialBaseAmount: create.baseAmount, + remainingBaseAmount: create.baseAmount, + }; + 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: 2 }); + 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 Record).Nonce; + return typeof parsed === 'number' ? parsed : undefined; + } catch { + return undefined; + } + }; + const takeStagedCreate = ( + txInfo: string, + ): StagedCreateBatch | undefined => { + const nonce = nonceFromTxInfo(txInfo); + const clientId = (JSON.parse(txInfo) as { ClientOrderIndex?: number }) + .ClientOrderIndex; + // A signed-but-never-dispatched create can share the next nonce with + // a retry. Match its actual wire client ID rather than committing the + // first signature staged under that nonce. + const at = stagedCreates.findIndex( + (batch) => + batch.nonce === nonce && + (clientId === undefined || + batch.creates.some((entry) => entry.clientOrderIndex === clientId)), + ); + 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: 0, + }); + } + 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: 2 }); + } + } + 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]), + baseAmount: String( + Number(wireParams[3]) / + 10 ** BTC_MARKET.supportedSizeDecimals, + ), + }, + ], + 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]), + baseAmount: String( + Number(wireParams[base + 2]) / + 10 ** BTC_MARKET.supportedSizeDecimals, + ), + }); + } + 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 { + const parsed = JSON.parse(result.txInfo ?? '') as Record< + string, + unknown + >; + wireNonce = + typeof parsed.Nonce === 'number' ? parsed.Nonce : undefined; + } 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.each([false, true])( + 'retains exact signed order expiry before dispatch and after settlement, grouped=%s', + async (grouped) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + setupTriggerVenue(built.clientInstance, built.bridge); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const send = built.clientInstance.sendTx.getMockImplementation() as + | ((type: number, info: string) => Promise) + | undefined; + if (!send) { + throw new Error('Missing test transport'); + } + const expected: { clientId: string; orderExpiry: number }[] = []; + const beforeDispatch: unknown[][] = []; + built.clientInstance.sendTx.mockImplementation( + async (type: number, info: string) => { + if (type === 14 || type === 28) { + const signed = JSON.parse(info) as { + ClientOrderIndex: number; + OrderExpiry: number; + Orders?: { ClientOrderIndex: number; OrderExpiry: number }[]; + }; + const rows = signed.Orders ?? [signed]; + expected.push( + ...rows.map((row) => ({ + clientId: String(row.ClientOrderIndex), + orderExpiry: row.OrderExpiry, + })), + ); + const stored = JSON.parse( + (await infra.diskCache.getItem(key)) ?? '', + ) as { orders: unknown[] }; + beforeDispatch.push(stored.orders); + } + return send(type, info); + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + ...(grouped ? { takeProfitPrice: '110000' } : {}), + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(expected).toHaveLength(grouped ? 2 : 1); + expect(beforeDispatch).toHaveLength(1); + for (const entry of expected) { + expect(beforeDispatch[0]).toContainEqual( + expect.objectContaining(entry), + ); + } + const stored = JSON.parse( + (await infra.diskCache.getItem(key)) ?? '', + ) as { orders: unknown[] }; + for (const entry of expected) { + expect(stored.orders).toContainEqual(expect.objectContaining(entry)); + } + }, + ); + + it.each(['buried', 'unavailable'])( + 'reclaims expired ownership at capacity with %s history across restart', + async (history) => { + const infra = createMockInfrastructure(); + const first = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + expect( + ( + await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const stored = JSON.parse( + (await infra.diskCache.getItem(key)) ?? '', + ) as { + orders: { clientId: string; orderId: string; orderExpiry: number }[]; + }; + const original = stored.orders[0]; + const signedCall = ( + first.clientInstance.sendTx.mock.calls as [number, string][] + ).find(([type]) => type === 14); + if (!original || !signedCall) { + throw new Error('Missing initial protection'); + } + const signed = JSON.parse(signedCall[1]) as { OrderExpiry: number }; + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(signed.OrderExpiry + 30_001); + try { + await first.provider.disconnect(); + const next = buildProvider({ platformDependencies: infra }); + const restartedVenue = setupTriggerVenue( + next.clientInstance, + next.bridge, + ); + const terminal = venue.rawTriggers[0]; + if (!terminal) { + throw new Error('Missing history template'); + } + restartedVenue.rawInactive.push({ + ...terminal, + status: 'canceled-oco', + }); + for (let index = 0; index < 100; index += 1) { + restartedVenue.rawInactive.push({ + ...terminal, + clientOrderIndex: 100_000 + index, + orderIndex: 100_000 + index, + status: 'canceled', + }); + } + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + orders: [ + original, + ...Array.from({ length: 255 }, (_, index) => ({ + clientId: String(index + 1), + orderId: null, + orderExpiry: signed.OrderExpiry, + })), + ], + }), + ); + if (history === 'unavailable') { + next.clientInstance.getInactiveOrders.mockRejectedValue( + new Error('history unavailable'), + ); + } + const result = await next.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + takeProfitPrice: '110000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(restartedVenue.rawTriggers).toHaveLength(2); + expect( + next.clientInstance.getInactiveOrders.mock.calls.length, + ).toBeLessThanOrEqual(1); + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toHaveLength(2); + } finally { + clock.mockRestore(); + } + }, + ); + + it.each(['future', 'slack', 'legacy', 'active'] as const)( + 'retains %s ownership at capacity and refuses new protection', + async (condition) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const now = Date.now(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(now); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const expiryOffset = { + future: 600_000, + slack: -30_000, + legacy: -30_001, + active: -30_001, + }[condition]; + const orders = Array.from({ length: 256 }, (_, index) => ({ + clientId: String(index + 1), + orderId: null, + ...(condition === 'legacy' + ? {} + : { + orderExpiry: now + expiryOffset, + }), + })); + try { + if (condition === 'active') { + for (const entry of orders) { + venue.seedTrigger('stop-loss', '90000'); + const row = venue.rawTriggers.at(-1); + if (!row) { + throw new Error('Missing active protection'); + } + row.clientOrderIndex = Number(entry.clientId); + row.type = 'stop-loss-limit'; + } + } + await infra.diskCache.setItem( + key, + JSON.stringify({ version: 1, orders }), + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('ownership is full'); + expect( + built.clientInstance.sendTx.mock.calls.filter(([type]: [number]) => + [14, 15, 28].includes(type), + ), + ).toHaveLength(0); + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toStrictEqual(orders); + } finally { + clock.mockRestore(); + } + }, + ); + + it.each([null, -1, 0, 1.5, '123456', Number.MAX_SAFE_INTEGER + 1])( + 'rejects corrupt persisted order expiry %s before mutation', + async (orderExpiry) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + setupTriggerVenue(built.clientInstance, built.bridge); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + orders: [{ clientId: '4242', orderId: null, orderExpiry }], + }), + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain( + 'Invalid Lighter managed TP/SL ownership', + ); + expect( + built.clientInstance.sendTx.mock.calls.filter(([type]: [number]) => + [14, 15, 28].includes(type), + ), + ).toHaveLength(0); + }, + ); + + it.each([ + [false, 'expiry'], + [true, 'expiry'], + [false, 'identity'], + [true, 'identity'], + [true, 'duplicate'], + [true, 'count'], + ] as const)( + 'refuses invalid signed order %s/%s proof before dispatch', + async (grouped, failure) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '90000'); + const execute = jest.spyOn(built.bridge, 'execute'); + const sign = execute.getMockImplementation() as + | (( + call: LighterWasmCall, + ) => Promise<{ txInfo: string; txHash: string }>) + | undefined; + if (!sign) { + throw new Error('Missing test signer'); + } + execute.mockImplementation(async (call: LighterWasmCall) => { + const result = await sign(call); + if ( + call.function === '_signCreateOrder' || + call.function === '_signCreateGroupedOrders' + ) { + const wire = JSON.parse(result.txInfo) as { + ClientOrderIndex: number; + OrderExpiry: number; + Orders?: { ClientOrderIndex: number; OrderExpiry: number }[]; + }; + const rows = wire.Orders ?? [wire]; + if (failure === 'expiry') { + rows[0].OrderExpiry = -1; + } else if (failure === 'identity') { + rows[0].ClientOrderIndex = 1234; + } else if (failure === 'duplicate') { + rows[1].ClientOrderIndex = rows[0].ClientOrderIndex; + } else { + wire.Orders?.pop(); + } + return { ...result, txInfo: JSON.stringify(wire) }; + } + return result; + }); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + ...(grouped ? { takeProfitPrice: '110000' } : {}), + }); + expect(result.success).toBe(false); + expect(result.error).toContain('no usable order expiry identity'); + expect(venue.rawTriggers).toHaveLength(1); + expect( + built.clientInstance.sendTx.mock.calls.filter(([type]: [number]) => + [14, 15, 28].includes(type), + ), + ).toHaveLength(0); + expect( + await infra.diskCache.getItem( + `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`, + ), + ).toBeNull(); + }, + ); + + it('bounds orphan ownership pruning without blocking protection on deep history', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '90000'); + const template = venue.rawTriggers.pop(); + if (!template) { + throw new Error('Test history template missing'); + } + for (let index = 0; index < 12_000; index += 1) { + venue.rawInactive.push({ + ...template, + orderIndex: 100_000 + index, + clientOrderIndex: 100_000 + index, + status: 'canceled', + }); + } + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + orders: [{ clientId: '4242', orderId: null }], + }), + ); + + for (const price of ['90000', '85000']) { + built.clientInstance.getInactiveOrders.mockClear(); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: price, + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(1); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledWith( + 28, + expect.any(String), + 100, + undefined, + 1, + ); + expect(venue.rawTriggers).toHaveLength(1); + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toContainEqual({ + clientId: '4242', + orderId: null, + }); + } + }); + + it('retains uncertain ownership and permits protection when bookkeeping history is unavailable', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + orders: [{ clientId: '4242', orderId: null }], + }), + ); + built.clientInstance.getInactiveOrders.mockRejectedValue( + new Error('history unavailable'), + ); + + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(1); + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toContainEqual({ + clientId: '4242', + orderId: null, + }); + }); + + it('permits retry after journal persistence and pre-dispatch ownership rollback both fail', async () => { + const infra = createMockInfrastructure(); + const disk = jest.mocked(infra.diskCache); + const write = disk.setItem.getMockImplementation(); + let payloadFailed = false; + let ownershipWrites = 0; + disk.setItem.mockImplementation(async (key: string, value: string) => { + if (!payloadFailed && key.startsWith('lighterTpslJournalOp:')) { + payloadFailed = true; + throw new Error('journal persistence failed'); + } + if (key.startsWith('lighterManagedTpsl:')) { + ownershipWrites += 1; + if (ownershipWrites === 2) { + throw new Error('ownership rollback failed'); + } + } + return write?.(key, value); + }); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const first = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }); + expect(first.success).toBe(false); + expect(venue.rawTriggers).toHaveLength(0); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const orphan = ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders[0]; + expect(orphan).toBeDefined(); + built.clientInstance.getInactiveOrders.mockRejectedValue( + new Error('history unavailable'), + ); + + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toContainEqual(orphan); + }); + + it('does not classify an observed accepted create as never landed after expiry', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + orders: [{ clientId: '4242', orderId: null }], + }), + ); + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 4, + recordedAt: Date.now() - 800_000, + operationId: 'accepted-unobserved-book', + createdAt: Date.now() - 800_000, + nextAttemptId: 2, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'accepted', + clientIds: [4242], + txHash: 'abab000000000042', + expiresAt: Date.now() - 700_000, + role: 'replacement', + }, + ], + }), + ); + + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('unresolved'); + expect(venue.rawTriggers).toHaveLength(0); + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toContainEqual({ clientId: '4242', orderId: null }); + }); + + it('retains live creation ownership when the extra post-settlement read lags empty', async () => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + const read = clientInstance.getActiveOrders.getMockImplementation() as ( + ...args: unknown[] + ) => Promise; + let reads = 0; + clientInstance.getActiveOrders.mockImplementation( + async (...args: unknown[]) => { + reads += 1; + if (reads === 4) { + return { code: 200, orders: [] }; + } + return read(...args); + }, + ); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toHaveLength(1); + clientInstance.getActiveOrders.mockImplementation(read); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.2' }], + }, + ], + }); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it.each([ + 'canceled-post-only', + 'canceled-reduce-only', + 'canceled-position-not-allowed', + 'canceled-margin-not-allowed', + 'canceled-too-much-slippage', + 'canceled-not-enough-liquidity', + 'canceled-self-trade', + 'canceled-expired', + 'canceled-oco', + 'canceled-child', + 'canceled-liquidation', + 'canceled-invalid-balance', + ])( + 'prunes exact %s terminal ownership and permits protection at capacity', + async (status) => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '90000'); + const terminal = venue.rawTriggers.pop(); + expect(terminal).toBeDefined(); + if (!terminal) { + throw new Error('Test terminal missing'); + } + venue.rawInactive.push({ ...terminal, status }); + const orders = Array.from({ length: 255 }, (_, index) => ({ + clientId: String(index + 1), + orderId: null, + })); + await infra.diskCache.setItem( + `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`, + JSON.stringify({ + version: 1, + orders: [ + ...orders, + { + clientId: String(terminal.clientOrderIndex), + orderId: String(terminal.orderIndex), + }, + ], + }), + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + }, + ); + + it('retries immediately after an ownership write fails once, without a never-sent journal', async () => { + const infra = createMockInfrastructure(); + const disk = jest.mocked(infra.diskCache); + const write = disk.setItem.getMockImplementation(); + let failed = false; + disk.setItem.mockImplementation(async (key: string, value: string) => { + if (!failed && key.startsWith('lighterManagedTpsl:')) { + failed = true; + throw new Error('one ownership write failure'); + } + return write?.(key, value); + }); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(false); + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + }); + + it.each(['lighterTpslJournalOp:', 'lighterTpslJournal:'])( + 'rolls back pre-dispatch ownership on a %s write failure and permits retry', + async (prefix) => { + const infra = createMockInfrastructure(); + const disk = jest.mocked(infra.diskCache); + const write = disk.setItem.getMockImplementation(); + let failed = false; + disk.setItem.mockImplementation(async (key: string, value: string) => { + if (!failed && key.startsWith(prefix)) { + failed = true; + throw new Error('one journal write failure'); + } + return write?.(key, value); + }); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(false); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toHaveLength(0); + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + }, + ); + + it('prunes an exact expired never-landed create without erasing an uncertain live identity', async () => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.failBeforeCommitOnce(14); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(false); + const later = Date.now() + 700_000; + const clock = jest.spyOn(Date, 'now').mockReturnValue(later); + try { + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + expect( + ( + JSON.parse((await infra.diskCache.getItem(key)) ?? '') as { + orders: unknown[]; + } + ).orders, + ).toHaveLength(1); + } finally { + clock.mockRestore(); + } + }); + + it.each(['read', 'write', 'corrupt'] as const)( + 'fails closed on managed ownership %s failure before protection mutation', + async (failure) => { + const infra = createMockInfrastructure(); + const diskMocks = jest.mocked(infra.diskCache); + const originalGet = diskMocks.getItem.getMockImplementation(); + if (!originalGet) { + throw new Error('Missing test disk reader'); + } + diskMocks.getItem.mockImplementation(async (key: string) => { + if (key.startsWith('lighterManagedTpsl:')) { + if (failure === 'read') { + throw new Error('ownership read failed'); + } + if (failure === 'corrupt') { + return '{"version":1,"orders":[{"clientId":"bad"}]}'; + } + } + return originalGet(key); + }); + const originalSet = diskMocks.setItem.getMockImplementation(); + diskMocks.setItem.mockImplementation( + async (key: string, value: string) => { + if (failure === 'write' && key.startsWith('lighterManagedTpsl:')) { + throw new Error('ownership write failed'); + } + return originalSet?.(key, value); + }, + ); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '90000'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect( + clientInstance.sendTx.mock.calls.filter(([type]: [number]) => + [14, 15, 28].includes(type), + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + }, + ); + + it('records creation ownership before dispatch and retains it through lost response and resize', async () => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.failResponseOnce(14); + const send = clientInstance.sendTx.getMockImplementation() as ( + type: number, + info: string, + ) => Promise; + const observedOwnership: unknown[] = []; + clientInstance.sendTx.mockImplementation( + async (type: number, info: string) => { + if (type === 14) { + const stored = await infra.diskCache.getItem( + `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`, + ); + observedOwnership.push( + (JSON.parse(stored ?? '') as { orders: unknown[] }).orders, + ); + } + return send?.(type, info); + }, + ); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(false); + expect(observedOwnership).toHaveLength(1); + expect(observedOwnership[0]).toHaveLength(1); + const originalId = venue.rawTriggers[0].orderIndex; + clientInstance.sendTx.mockImplementation(send); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.2' }], + }, + ], + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].orderIndex).not.toBe(originalId); + const stored = await infra.diskCache.getItem( + `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`, + ); + expect( + (JSON.parse(stored ?? '') as { orders: unknown[] }).orders, + ).toHaveLength(1); + }); + + it('keeps managed ownership across trading key slots and prunes exact terminal history', async () => { + const infra = createMockInfrastructure(); + const first = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + expect( + ( + await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + await first.provider.disconnect(); + const next = buildProvider({ + platformDependencies: infra, + apiKeyIndex: 8, + }); + const nextVenue = setupTriggerVenue(next.clientInstance, next.bridge, { + apiKeyIndex: 8, + }); + nextVenue.rawTriggers.push(...venue.rawTriggers); + nextVenue.setNextIndex(venue.getNextIndex()); + next.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.2' }], + }, + ], + }); + expect( + ( + await next.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + expect(nextVenue.rawTriggers).toHaveLength(1); + nextVenue.rawInactive.push({ + ...nextVenue.rawTriggers[0], + status: 'filled', + remainingBaseAmount: '0', + }); + nextVenue.rawTriggers.splice(0); + expect( + ( + await next.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }) + ).success, + ).toBe(true); + const stored = await infra.diskCache.getItem( + `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`, + ); + expect( + (JSON.parse(stored ?? '') as { orders: unknown[] }).orders, + ).toHaveLength(1); + }); + + it.each(['0.2', '0.05'])( + 'retains managed TP/SL ownership after resizing to %s and restarting', + async (size) => { + const infra = createMockInfrastructure(); + const first = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + expect( + ( + await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const originalId = venue.rawTriggers[0].orderIndex; + await first.provider.disconnect(); + const next = buildProvider({ platformDependencies: infra }); + const nextVenue = setupTriggerVenue(next.clientInstance, next.bridge); + nextVenue.rawTriggers.push(...venue.rawTriggers); + nextVenue.setNextIndex(venue.getNextIndex()); + next.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: size }], + }, + ], + }); + expect( + ( + await next.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + expect(nextVenue.rawTriggers).toHaveLength(1); + expect(nextVenue.rawTriggers[0].orderIndex).not.toBe(originalId); + expect(nextVenue.rawTriggers[0].remainingBaseAmount).toBe(size); + // Resize again before removal. Quantity is never the managed identity. + next.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ACCOUNT], + }); + expect( + (await next.provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect(nextVenue.rawTriggers).toHaveLength(0); + }, + ); + + it.each([ + { position: '0.2', sign: 1 }, + { position: '0.1', sign: -1 }, + ])( + 'uses the in-lock position for legacy removal after %j', + async (live) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '90000'); + const read = clientInstance.getActiveOrders.getMockImplementation() as ( + ...args: unknown[] + ) => Promise; + clientInstance.getActiveOrders.mockImplementation( + async (...args: unknown[]) => { + // Public preflight already saw .1 long. The book read is inside the lock. + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], ...live }], + }, + ], + }); + return read?.(...args); + }, + ); + expect( + (await provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + }, + ); + + it.each(['absent', 'zero', 'subtick', 'invalid-sign'] as const)( + 'removes only durable managed protection after live position becomes %s', + async (state) => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge, calls } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const managed = { ...venue.rawTriggers[0] }; + const managedKey = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + expect(await infra.diskCache.getItem(managedKey)).toContain( + String(managed.clientOrderIndex), + ); + for (const type of ['stop-loss', 'stop-loss-limit', 'limit']) { + venue.seedTrigger(type, '85000'); + } + const buySide = venue.seedTrigger('stop-loss', '85000'); + const buySideRow = venue.rawTriggers.find( + (row) => row.orderIndex === buySide, + ); + if (!buySideRow) { + throw new Error('Missing unrecorded buy-side trigger'); + } + buySideRow.isAsk = false; + const partial = venue.seedTrigger('take-profit', '110000'); + const partialRow = venue.rawTriggers.find( + (row) => row.orderIndex === partial, + ); + if (!partialRow) { + throw new Error('Missing independent order'); + } + partialRow.initialBaseAmount = '0.001'; + partialRow.remainingBaseAmount = '0.001'; + const foreign = venue.seedTrigger('stop-loss', '85000'); + const foreignRow = venue.rawTriggers.find( + (row) => row.orderIndex === foreign, + ); + if (!foreignRow) { + throw new Error('Missing foreign order'); + } + foreignRow.ownerAccountIndex = 29; + const preserved = venue.rawTriggers + .filter((row) => row.orderIndex !== managed.orderIndex) + .map((row) => ({ ...row })); + const sizes = { + zero: '0', + subtick: '0.000000001', + 'invalid-sign': '0.1', + }; + clientInstance.getAccountByIndex + .mockResolvedValueOnce({ code: 200, accounts: [ACCOUNT] }) + .mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: + state === 'absent' + ? [] + : [ + { + ...ACCOUNT.positions[0], + position: sizes[state], + sign: state === 'invalid-sign' ? 0 : 1, + }, + ], + }, + ], + }); + calls.length = 0; + const result = await provider.updatePositionTPSL({ symbol: 'BTC' }); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect( + calls + .filter((call) => call.function === '_signCancelOrder') + .map((call) => String(call.params[2])), + ).toStrictEqual([String(managed.orderIndex)]); + expect( + calls.filter( + (call) => + call.function === '_signCreateOrder' || + call.function === '_signCreateGroupedOrders', + ), + ).toStrictEqual([]); + expect(venue.rawTriggers).toStrictEqual(preserved); + expect(await infra.diskCache.getItem(managedKey)).toBe( + JSON.stringify({ version: 1, orders: [] }), + ); + }, + ); + + it.each([ + 'read-failure', + 'missing-positions', + 'malformed-positions', + 'wrong-account', + 'missing-account', + ] as const)( + 'refuses managed removal with %s live account evidence', + async (state) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const before = venue.rawTriggers.map((row) => ({ ...row })); + clientInstance.getAccountByIndex.mockResolvedValueOnce({ + code: 200, + accounts: [ACCOUNT], + }); + if (state === 'read-failure') { + clientInstance.getAccountByIndex.mockRejectedValue( + new Error('unavailable'), + ); + } else { + const invalidPositions = + state === 'missing-positions' ? undefined : {}; + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: + state === 'missing-account' + ? [] + : [ + { + ...ACCOUNT, + index: state === 'wrong-account' ? 29 : 28, + positions: + state === 'missing-positions' || + state === 'malformed-positions' + ? invalidPositions + : [], + }, + ], + }); + } + calls.length = 0; + const result = await provider.updatePositionTPSL({ symbol: 'BTC' }); + expect(result.success).toBe(false); + expect( + calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(venue.rawTriggers).toStrictEqual(before); + }, + ); + + it.each(['absent', 'zero', 'subtick', 'side-drift'] as const)( + 'still refuses replacement after live position becomes %s', + async (state) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const before = venue.rawTriggers.map((row) => ({ ...row })); + clientInstance.getAccountByIndex + .mockResolvedValueOnce({ code: 200, accounts: [ACCOUNT] }) + .mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: + state === 'absent' + ? [] + : [ + { + ...ACCOUNT.positions[0], + position: { + zero: '0', + subtick: '0.000000001', + absent: '0.1', + 'side-drift': '0.1', + }[state], + sign: state === 'side-drift' ? -1 : 1, + }, + ], + }, + ], + }); + calls.length = 0; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect( + calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(venue.rawTriggers).toStrictEqual(before); + }, + ); + + it.each([ + { name: 'growth', position: '0.2', sign: 1, changed: true }, + { name: 'shrink', position: '0.05', sign: 1, changed: true }, + { name: 'side flip', position: '0.1', sign: -1, changed: true }, + { name: 'unchanged', position: '0.1', sign: 1, changed: false }, + ])( + 'rechecks the position after held ownership storage: $name', + async ({ position, sign, changed }) => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge, calls } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const before = venue.rawTriggers.map((row) => ({ ...row })); + const managedKey = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const originalRead = infra.diskCache.getItem.bind(infra.diskCache); + const entered = createDeferred(); + const release = createDeferred(); + let managedReads = 0; + infra.diskCache.getItem = async ( + key: string, + ): Promise => { + // Pruning reads ownership first. Hold the second read, after the + // original implementation's authoritative position check. + if (key === managedKey) { + managedReads += 1; + } + if (key === managedKey && managedReads === 2) { + entered.resolve(); + await release.promise; + } + return originalRead(key); + }; + calls.length = 0; + const pending = provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + await entered.promise; + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position, sign }], + }, + ], + }); + release.resolve(); + const result = await pending; + expect(result.success).toBe(!changed); + expect( + result.error?.includes('position changed before TP/SL signing') ?? + false, + ).toBe(changed); + expect( + calls.filter( + (call) => + call.function === '_signCreateOrder' || + call.function === '_signCreateGroupedOrders' || + call.function === '_signCancelOrder', + ), + ).toHaveLength(changed ? 0 : 2); + expect(venue.rawTriggers).toStrictEqual( + changed + ? before + : [{ ...venue.rawTriggers[0], triggerPrice: '85000' }], + ); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe( + changed ? '90000' : '85000', + ); + }, + ); + + it('accepts legacy GTT trigger-market protection with a restorable wire intent', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '90000'); + venue.rawTriggers[0].timeInForce = 'good-till-time'; + expect( + (await provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect(venue.rawTriggers).toHaveLength(0); + }); + + it.each([ + ['stop_market', '0.001'], + ['take_profit_market', '0.001'], + ['stop_limit', '0.001'], + ['take_profit_limit', '0.001'], + ['stop_limit', '0.1'], + ['take_profit_limit', '0.1'], + ] as const)( + 'preserves a standalone reduce-only %s of size %s through position TP/SL replacement and removal', + async (orderType, size) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const placed = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size, + orderType, + triggerPrice: '95000', + ...(orderType.endsWith('_limit') && { price: '95100' }), + reduceOnly: true, + }); + const standalone = venue.rawTriggers.find( + (row) => String(row.clientOrderIndex) === placed.orderId, + ); + expect(placed.success).toBe(true); + expect(standalone).toBeDefined(); + + const updated = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + const removed = await provider.updatePositionTPSL({ symbol: 'BTC' }); + + expect(updated.success).toBe(true); + expect(removed.success).toBe(true); + expect(venue.rawTriggers).toContainEqual(standalone); + expect( + calls + .filter((call) => call.function === '_signCancelOrder') + .some( + (call) => + String(call.params[2]) === String(standalone?.orderIndex), + ), + ).toBe(false); + }, + ); + it.each(['stop_market', 'take_profit_market'] as const)( + 'replaces a standalone full-position %s under the documented position-protection contract', + async (orderType) => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const placed = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.1', + orderType, + triggerPrice: '95000', + reduceOnly: true, + }); + + const updated = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + }); + + expect(updated.success).toBe(true); + expect( + venue.rawTriggers.some( + (row) => String(row.clientOrderIndex) === placed.orderId, + ), + ).toBe(false); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('110000'); + }, + ); + 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.each(['single', 'pair', 'filled', 'oco-mixed'] as const)( + 'returns exact position protection receipts for %s', + async (mode) => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + if (mode === 'filled' || mode === 'oco-mixed') { + venue.setCreateTerminal(mode); + } + const pair = mode === 'pair' || mode === 'oco-mixed'; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + ...(pair ? { takeProfitPrice: '110000' } : {}), + }); + expect(result.success).toBe(true); + const created = [...venue.rawTriggers, ...venue.rawInactive].filter( + (row) => row.orderIndex !== oldId, + ); + expect(result.childOrderIds).toStrictEqual( + created.map((row) => String(row.orderIndex)), + ); + expect(result.childOrderIds).toHaveLength(pair ? 2 : 1); + }, + ); + + it('ignores conflicting inactive identities outside the requested clients', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.setCreateTerminal('filled'); + clientInstance.getInactiveOrders.mockImplementation(async () => ({ + code: 200, + orders: [ + ...venue.rawInactive, + ...venue.rawInactive.flatMap((row) => [ + { ...row, clientOrderIndex: 0, orderIndex: 501 }, + { ...row, clientOrderIndex: 0, orderIndex: 502 }, + ]), + ], + })); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(true); + expect(result.childOrderIds).toStrictEqual( + venue.rawInactive.map((row) => String(row.orderIndex)), + ); + }); + + it.each([ + { size: '0.2', entryPrice: '100000' }, + { size: '-0.001', entryPrice: '100000' }, + { size: '0.001', entryPrice: '99999' }, + ])( + 'rejects a different expected protection position %j', + async (expectedPosition) => { + const { provider, calls } = buildProvider(); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + expectedPosition, + }); + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect( + calls.filter((call) => call.function.startsWith('_sign')), + ).toHaveLength(0); + }, + ); + + it.each(['resize', 'flip', 'vanished'])( + 'returns the structured error for intermediate position %s before signing', + async (change) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + clientInstance.getActiveOrders.mockImplementation(async () => { + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: + change === 'vanished' + ? [] + : [ + { + ...ACCOUNT.positions[0], + position: + change === 'resize' + ? '0.002' + : ACCOUNT.positions[0].position, + sign: change === 'flip' ? -1 : 1, + }, + ], + }, + ], + }); + return { + code: 200, + orders: venue.rawTriggers.map((row) => ({ ...row })), + }; + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(result.success).toBe(false); + expect( + calls.filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ), + ).toHaveLength(0); + expect( + clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === 14 || kind === 15, + ), + ).toHaveLength(0); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + oldId, + ]); + }, + ); + + it.each(['absent', 'zero', 'subtick', 'invalid-sign'] as const)( + 'retains explicit removal preconditions when live position becomes %s', + async (state) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const before = venue.rawTriggers.map((row) => ({ ...row })); + const positions = { + zero: '0', + subtick: '0.000000001', + 'invalid-sign': ACCOUNT.positions[0].position, + }; + let driftInjected = false; + clientInstance.getActiveOrders.mockImplementation(async () => { + if (!driftInjected) { + driftInjected = true; + // Only the post-prune read sees drift. Later final readers see + // valid evidence, so this specifically protects that boundary. + clientInstance.getAccountByIndex.mockResolvedValueOnce({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: + state === 'absent' + ? [] + : [ + { + ...ACCOUNT.positions[0], + position: positions[state], + sign: state === 'invalid-sign' ? 0 : 1, + }, + ], + }, + ], + }); + } + return { + code: 200, + orders: venue.rawTriggers.map((row) => ({ ...row })), + }; + }); + calls.length = 0; + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(driftInjected).toBe(true); + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect( + calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(venue.rawTriggers).toStrictEqual(before); + }, + ); + + describe('final expected-position authority after signing', () => { + for (const operation of ['create', 'remove'] as const) { + it.each([ + 'wrong-account', + 'duplicate-account', + 'missing-positions', + 'malformed-positions', + 'sign-zero', + 'sign-two', + ] as const)( + `refuses ${operation} with %s evidence and permits a refreshed retry`, + async (evidence) => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '80000', + }) + ).success, + ).toBe(true); + const oldOrders = venue.rawTriggers.map((row) => ({ ...row })); + const managedKey = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const journalKey = `lighterTpslJournal:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ownership = await infra.diskCache.getItem(managedKey); + const ledgerBefore = await infra.diskCache.getItem(ledgerKey); + expect(ownership).not.toBeNull(); + expect(ledgerBefore).not.toBeNull(); + const expectedPosition = { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }; + const started = createDeferred(); + const release = createDeferred(); + const execute = jest.spyOn(bridge, 'execute'); + const original = execute.getMockImplementation(); + if (!original) { + throw new Error('Expected configured Lighter bridge'); + } + execute.mockImplementation(async (call: LighterWasmCall) => { + const signed = await original(call); + if ( + call.function === + (operation === 'create' + ? '_signCreateOrder' + : '_signCancelOrder') + ) { + started.resolve(); + await release.promise; + } + return signed; + }); + const pending = provider.updatePositionTPSL({ + symbol: 'BTC', + ...(operation === 'create' ? { stopLossPrice: '85000' } : {}), + expectedPosition, + }); + await started.promise; + clientInstance.sendTx.mockClear(); + const invalidAccount = { + ...ACCOUNT, + ...(evidence === 'wrong-account' ? { index: 29 } : {}), + ...(evidence === 'missing-positions' + ? { positions: undefined } + : {}), + ...(evidence === 'malformed-positions' ? { positions: {} } : {}), + ...(evidence === 'sign-zero' || evidence === 'sign-two' + ? { + positions: [ + { + ...ACCOUNT.positions[0], + sign: evidence === 'sign-zero' ? 0 : 2, + }, + ], + } + : {}), + }; + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: + evidence === 'duplicate-account' + ? [invalidAccount, invalidAccount] + : [invalidAccount], + }); + release.resolve(); + const result = await pending; + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers).toStrictEqual(oldOrders); + expect(await infra.diskCache.getItem(managedKey)).toBe(ownership); + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + expect(await infra.diskCache.getItem(ledgerKey)).toBe(ledgerBefore); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ACCOUNT], + }); + execute.mockImplementation(original); + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + ...(operation === 'create' ? { stopLossPrice: '85000' } : {}), + expectedPosition, + }); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength( + operation === 'create' ? 1 : 0, + ); + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + await provider.disconnect(); + }, + ); + } + }); + + it.each(['read', 'restart'] as const)( + 'clears fully dispatched guarded removal after foreground book timeout and %s recovery', + async (recoveryMode) => { + const infra = createMockInfrastructure(); + const original = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue( + original.clientInstance, + original.bridge, + ); + venue.seedTrigger('take-profit', '110000'); + venue.seedTrigger('stop-loss', '80000'); + const initialOrders = venue.rawTriggers.map((row) => ({ ...row })); + venue.setRestLag(100); + const result = await original.provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('settlement is not yet visible'); + expect(original.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect(venue.rawTriggers).toStrictEqual([]); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const journalKey = `lighterTpslJournal:testnet:${settlementKey}`; + expect(await infra.diskCache.getItem(journalKey)).not.toBeNull(); + let recovering = original; + let recoveryVenue = venue; + if (recoveryMode === 'restart') { + await original.provider.disconnect(); + recovering = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + recoveryVenue = setupTriggerVenue( + recovering.clientInstance, + recovering.bridge, + ); + recoveryVenue.setVenueNonce(venue.getVenueNonce()); + for (const [hash, transaction] of venue.landedTxs) { + recoveryVenue.landedTxs.set(hash, transaction); + } + } + recoveryVenue.primeLag(initialOrders, 100); + const submissions = recovering.clientInstance.sendTx.mock.calls.length; + await recovering.provider.getOpenOrders(); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(await infra.diskCache.getItem(journalKey)).not.toBeNull(); + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissions, + ); + // All attempts have landed, but recovery must retain ownership while + // their target orders are still visible in the lagging venue read. + recoveryVenue.setRestLag(0); + recoveryVenue.primeLag([], 0); + for (let attempt = 0; attempt < 40; attempt += 1) { + await recovering.provider.getOpenOrders(); + if ((await infra.diskCache.getItem(journalKey)) === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([]); + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissions, + ); + expect(recoveryVenue.rawTriggers).toStrictEqual([]); + await recovering.provider.disconnect(); + }, + ); + + it.each([ + ['read', 'resize'], + ['restart', 'resize'], + ['read', 'entry-drift'], + ['restart', 'entry-drift'], + ] as const)( + 'parks a partially dispatched removal with %s recovery after %s during the second signature', + async (recoveryMode, change) => { + const infra = createMockInfrastructure(); + const original = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue( + original.clientInstance, + original.bridge, + ); + const firstId = venue.seedTrigger('take-profit', '110000'); + const survivingId = venue.seedTrigger('stop-loss', '80000'); + const initialOrders = venue.rawTriggers.map((row) => ({ ...row })); + const managedKey = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + await infra.diskCache.setItem( + managedKey, + JSON.stringify({ + version: 1, + orders: initialOrders.map((row) => ({ + clientId: String(row.clientOrderIndex), + orderId: String(row.orderIndex), + })), + }), + ); + const livePosition = { + ...ACCOUNT.positions[0], + ...(change === 'resize' + ? { position: '0.2' } + : { avgEntryPrice: '100001' }), + }; + const execute = jest.spyOn(original.bridge, 'execute'); + const implementation = execute.getMockImplementation(); + if (!implementation) { + throw new Error('Expected configured Lighter signer bridge'); + } + let cancellationSignatures = 0; + execute.mockImplementation(async (call: LighterWasmCall) => { + const signed = await implementation(call); + if (call.function === '_signCancelOrder') { + cancellationSignatures += 1; + if (cancellationSignatures === 2) { + original.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [livePosition] }], + }); + } + } + return signed; + }); + venue.setRestLag(100); + const result = await original.provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(cancellationSignatures).toBe(2); + expect(original.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + survivingId, + ]); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const journalKey = `lighterTpslJournal:testnet:${settlementKey}`; + const pointer = JSON.parse( + (await infra.diskCache.getItem(journalKey)) ?? '', + ) as { operationId: string }; + const payloadKey = `lighterTpslJournalOp:testnet:${settlementKey}:${pointer.operationId}`; + const payload = JSON.parse( + (await infra.diskCache.getItem(payloadKey)) ?? '', + ) as { attempts: { kind: string; orderId: string; outcome: string }[] }; + expect(payload.attempts).toStrictEqual([ + expect.objectContaining({ + kind: 'cancel', + orderId: String(firstId), + outcome: 'accepted', + }), + ]); + + let recovering = original; + let recoveryVenue = venue; + if (recoveryMode === 'restart') { + await original.provider.disconnect(); + recovering = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + recoveryVenue = setupTriggerVenue( + recovering.clientInstance, + recovering.bridge, + ); + recoveryVenue.rawTriggers.push(...venue.rawTriggers); + recoveryVenue.setVenueNonce(venue.getVenueNonce()); + for (const [hash, transaction] of venue.landedTxs) { + recoveryVenue.landedTxs.set(hash, transaction); + } + recoveryVenue.primeLag(initialOrders, 100); + recovering.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [livePosition] }], + }); + } + const submissionsBefore = + recovering.clientInstance.sendTx.mock.calls.length; + const lookupFinished = createDeferred(); + const lookup = + recovering.clientInstance.getTx.getMockImplementation() as + | ((hash: string) => Promise) + | undefined; + if (!lookup) { + throw new Error('Expected configured transaction lookup'); + } + recovering.clientInstance.getTx.mockImplementation( + async (hash: string) => { + const transaction = await lookup(hash); + lookupFinished.resolve(); + return transaction; + }, + ); + await recovering.provider.getOpenOrders(); + await lookupFinished.promise; + await new Promise((resolve) => setImmediate(resolve)); + // Accepted first cancellation is still hidden by the book snapshot. + // Its obligation must remain durable while later cancellation stays + // refused, including across a fresh provider lifetime. + expect(await infra.diskCache.getItem(journalKey)).not.toBeNull(); + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissionsBefore, + ); + expect( + recoveryVenue.rawTriggers.map((row) => row.orderIndex), + ).toStrictEqual([survivingId]); + + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([ + expect.objectContaining({ + priorIntent: 'remove', + reason: expect.stringContaining( + 'position precondition failed', + ) as string, + }), + ]); + recoveryVenue.setRestLag(0); + recoveryVenue.primeLag([], 0); + for (let attempt = 0; attempt < 40; attempt += 1) { + await recovering.provider.getOpenOrders(); + if ((await infra.diskCache.getItem(journalKey)) === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissionsBefore, + ); + expect( + recoveryVenue.rawTriggers.map((row) => row.orderIndex), + ).toStrictEqual([survivingId]); + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([ + expect.objectContaining({ + symbol: 'BTC', + settlementKey, + priorIntent: 'remove', + reason: expect.stringContaining( + 'position precondition failed', + ) as string, + survivingOrderIds: [String(survivingId)], + actionNeeded: expect.stringContaining( + 'new explicit TP/SL removal', + ) as string, + }), + ]); + const refreshed = await recovering.provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: livePosition.position, + entryPrice: livePosition.avgEntryPrice, + }, + }); + expect(refreshed.success).toBe(true); + expect(recoveryVenue.rawTriggers).toStrictEqual([]); + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissionsBefore + 1, + ); + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([]); + await recovering.provider.disconnect(); + }, + ); + + it.each(['read', 'restart'] as const)( + 'never resumes guarded removal after refused-phase storage failure, signed expiry and %s recovery', + async (recoveryMode) => { + const infra = createMockInfrastructure(); + const write = jest.spyOn(infra.diskCache, 'setItem'); + const originalWrite = write.getMockImplementation(); + if (!originalWrite) { + throw new Error('Expected disk fixture'); + } + let failManualWrite = false; + let rejectedWrites = 0; + write.mockImplementation(async (key: string, value: string) => { + if (failManualWrite && key.startsWith('lighterTpslJournalOp:')) { + const payload = JSON.parse(value) as { + phase: string; + attempts: { outcome: string }[]; + }; + if ( + payload.phase === 'manual' && + payload.attempts.length === 1 && + payload.attempts[0].outcome === 'accepted' + ) { + rejectedWrites += 1; + throw new Error('manual refusal payload write failed'); + } + } + return await originalWrite(key, value); + }); + const original = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue( + original.clientInstance, + original.bridge, + ); + const firstId = venue.seedTrigger('take-profit', '110000'); + const survivingId = venue.seedTrigger('stop-loss', '80000'); + const initialOrders = venue.rawTriggers.map((row) => ({ ...row })); + const managedKey = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + await infra.diskCache.setItem( + managedKey, + JSON.stringify({ + version: 1, + orders: initialOrders.map((row) => ({ + clientId: String(row.clientOrderIndex), + orderId: String(row.orderIndex), + })), + }), + ); + const livePosition = { ...ACCOUNT.positions[0], position: '0.2' }; + const execute = jest.spyOn(original.bridge, 'execute'); + const implementation = execute.getMockImplementation(); + if (!implementation) { + throw new Error('Expected configured Lighter signer bridge'); + } + let cancellationSignatures = 0; + execute.mockImplementation(async (call: LighterWasmCall) => { + const signed = await implementation(call); + if (call.function === '_signCancelOrder') { + cancellationSignatures += 1; + if (cancellationSignatures === 2) { + failManualWrite = true; + original.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [livePosition] }], + }); + } + } + return signed; + }); + venue.setRestLag(100); + const result = await original.provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(cancellationSignatures).toBe(2); + expect(rejectedWrites).toBeGreaterThan(0); + failManualWrite = false; + expect(original.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + survivingId, + ]); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const journalKey = `lighterTpslJournal:testnet:${settlementKey}`; + const pointer = JSON.parse( + (await infra.diskCache.getItem(journalKey)) ?? '', + ) as { operationId: string }; + const payloadKey = `lighterTpslJournalOp:testnet:${settlementKey}:${pointer.operationId}`; + const payload = JSON.parse( + (await infra.diskCache.getItem(payloadKey)) ?? '', + ) as { attempts: { kind: string; orderId: string; outcome: string }[] }; + expect(payload.attempts).toStrictEqual([ + expect.objectContaining({ + kind: 'cancel', + orderId: String(firstId), + outcome: 'accepted', + }), + expect.objectContaining({ + kind: 'cancel', + orderId: String(survivingId), + outcome: 'unknown', + }), + ]); + + const originalNow = Date.now.bind(Date); + const clock = jest + .spyOn(Date, 'now') + .mockImplementation(() => originalNow() + 700_000); + try { + let recovering = original; + let recoveryVenue = venue; + if (recoveryMode === 'restart') { + await original.provider.disconnect(); + recovering = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + recoveryVenue = setupTriggerVenue( + recovering.clientInstance, + recovering.bridge, + ); + recoveryVenue.rawTriggers.push(...venue.rawTriggers); + recoveryVenue.setVenueNonce(venue.getVenueNonce()); + for (const [hash, transaction] of venue.landedTxs) { + recoveryVenue.landedTxs.set(hash, transaction); + } + recoveryVenue.primeLag(initialOrders, 100); + recovering.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [livePosition] }], + }); + } + const submissionsBefore = + recovering.clientInstance.sendTx.mock.calls.length; + const lookupFinished = createDeferred(); + const lookup = + recovering.clientInstance.getTx.getMockImplementation() as + | ((hash: string) => Promise) + | undefined; + if (!lookup) { + throw new Error('Expected configured transaction lookup'); + } + recovering.clientInstance.getTx.mockImplementation( + async (hash: string) => { + const transaction = await lookup(hash); + lookupFinished.resolve(); + return transaction; + }, + ); + await recovering.provider.getOpenOrders(); + await lookupFinished.promise; + await new Promise((resolve) => setImmediate(resolve)); + // Accepted first cancellation is still hidden by the book snapshot. + // Its obligation must remain durable while later cancellation stays + // refused, including across a fresh provider lifetime. + expect(await infra.diskCache.getItem(journalKey)).not.toBeNull(); + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissionsBefore, + ); + expect( + recoveryVenue.rawTriggers.map((row) => row.orderIndex), + ).toStrictEqual([survivingId]); + + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([ + expect.objectContaining({ + priorIntent: 'remove', + reason: expect.stringContaining( + 'position precondition failed', + ) as string, + }), + ]); + recoveryVenue.setRestLag(0); + recoveryVenue.primeLag([], 0); + for (let attempt = 0; attempt < 80; attempt += 1) { + await recovering.provider.getOpenOrders(); + if ((await infra.diskCache.getItem(journalKey)) === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissionsBefore, + ); + expect( + recoveryVenue.rawTriggers.map((row) => row.orderIndex), + ).toStrictEqual([survivingId]); + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([ + expect.objectContaining({ + symbol: 'BTC', + settlementKey, + priorIntent: 'remove', + reason: expect.stringContaining( + 'position precondition failed', + ) as string, + survivingOrderIds: [String(survivingId)], + }), + ]); + const refreshed = await recovering.provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: livePosition.position, + entryPrice: livePosition.avgEntryPrice, + }, + }); + expect(refreshed.success).toBe(true); + expect(recoveryVenue.rawTriggers).toStrictEqual([]); + expect(recovering.clientInstance.sendTx).toHaveBeenCalledTimes( + submissionsBefore + 1, + ); + expect( + await recovering.provider.getPendingManualRecoveries(), + ).toStrictEqual([]); + await recovering.provider.disconnect(); + } finally { + clock.mockRestore(); + } + }, + ); + + it.each(['resize', 'entry-drift'])( + 'releases an unsent removal after position %s during cancel signing', + async (change) => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + expect( + ( + await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '80000', + }) + ).success, + ).toBe(true); + const oldIds = venue.rawTriggers.map((row) => row.orderIndex); + const managedKey = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const ownership = await infra.diskCache.getItem(managedKey); + const started = createDeferred(); + const release = createDeferred(); + const execute = jest.spyOn(bridge, 'execute'); + const original = execute.getMockImplementation(); + if (!original) { + throw new Error('Expected configured Lighter bridge implementation'); + } + execute.mockImplementation(async (call: LighterWasmCall) => { + const signed = await original(call); + if (call.function === '_signCancelOrder') { + started.resolve(); + await release.promise; + } + return signed; + }); + const pending = provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + await started.promise; + clientInstance.sendTx.mockClear(); + const live = { + ...ACCOUNT.positions[0], + ...(change === 'resize' + ? { position: '0.002' } + : { avgEntryPrice: '100001' }), + }; + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [live] }], + }); + release.resolve(); + const result = await pending; + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(result.success).toBe(false); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual( + oldIds, + ); + expect(await infra.diskCache.getItem(managedKey)).toBe(ownership); + expect( + await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`, + ), + ).toBeNull(); + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + expectedPosition: { + size: live.position, + entryPrice: live.avgEntryPrice, + }, + }); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(0); + expect((await provider.closePosition({ symbol: 'BTC' })).success).toBe( + true, + ); + }, + ); + + it.each(['resize', 'entry-drift'])( + 'rejects position %s while create signing is pending before dispatch', + async (change) => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + let releaseSigning: () => void = () => undefined; + let markSigningStarted: () => void = () => undefined; + const signingStarted = new Promise((resolve) => { + markSigningStarted = resolve; + }); + const signingPending = new Promise((resolve) => { + releaseSigning = resolve; + }); + const originalExecute = ( + bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const signed = await originalExecute(call); + if (call.function === '_signCreateOrder') { + markSigningStarted(); + await signingPending; + } + return signed; + }, + ); + const pending = provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + await signingStarted; + clientInstance.sendTx.mockClear(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + ...(change === 'resize' + ? { position: '0.002' } + : { avgEntryPrice: '100001' }), + }, + ], + }, + ], + }); + releaseSigning(); + const result = await pending; + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + oldId, + ]); + const retry = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + expectedPosition: { + size: change === 'resize' ? '0.002' : ACCOUNT.positions[0].position, + entryPrice: + change === 'entry-drift' + ? '100001' + : ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(retry.success).toBe(true); + const close = await provider.closePosition({ symbol: 'BTC' }); + expect(close.success).toBe(true); + }, + ); + + it.each(['unchanged', 'entry-drift'])( + 'checks expected position through signer setup: %s', + async (mode) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + let reads = 0; + clientInstance.getAccountByIndex.mockImplementation(async () => { + reads += 1; + return { + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + avgEntryPrice: + mode === 'entry-drift' && reads >= 3 + ? '100001' + : ACCOUNT.positions[0].avgEntryPrice, + }, + ], + }, + ], + }; + }); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + expectedPosition: { + size: ACCOUNT.positions[0].position, + entryPrice: ACCOUNT.positions[0].avgEntryPrice, + }, + }); + expect(result.success).toBe(mode === 'unchanged'); + expect(venue.rawTriggers.some((row) => row.orderIndex === oldId)).toBe( + mode !== 'unchanged', + ); + expect( + calls.filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ), + ).toHaveLength(mode === 'unchanged' ? 2 : 0); + }, + ); + + it('retains protection and journal when inactive receipt identities conflict', async () => { + const infra = createMockInfrastructure(); + const removeItem = jest.spyOn(infra.diskCache, 'removeItem'); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + venue.setCreateTerminal('filled'); + clientInstance.getInactiveOrders.mockImplementation(async () => ({ + code: 200, + orders: venue.rawInactive.flatMap((row) => [ + row, + { ...row, orderIndex: row.orderIndex + 100 }, + ]), + })); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('conflicting inactive-order identity'); + expect(result.childOrderIds).toBeUndefined(); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toContain(oldId); + expect(removeItem).not.toHaveBeenCalledWith( + expect.stringContaining('lighterTpslJournal:'), + ); + }); + + it.each(['take-profit', 'stop-loss'])( + 'preserves TP/SL receipt order when %s executes after the barrier', + async (executedType) => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + const read = clientInstance.getActiveOrders.getMockImplementation(); + if (!read) { + throw new Error('missing venue reader'); + } + let creationReads = 0; + const expected = new Map(); + clientInstance.getActiveOrders.mockImplementation( + async (...args: unknown[]) => { + const response = (await read(...args)) as { + orders: RawTriggerOrder[]; + }; + const created = venue.rawTriggers.filter( + (row) => row.orderIndex !== oldId, + ); + if (created.length === 2) { + creationReads += 1; + for (const row of created) { + expected.set(row.type, String(row.orderIndex)); + } + if (creationReads === 2) { + venue.rawTriggers.splice(0, venue.rawTriggers.length); + venue.rawInactive.push( + ...created.reverse().map((row) => ({ + ...row, + status: row.type === executedType ? 'filled' : 'canceled', + remainingBaseAmount: + row.type === executedType ? '0' : row.remainingBaseAmount, + })), + ); + return { ...response, orders: [] }; + } + } + return response; + }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + }); + expect(result.success).toBe(true); + expect(result.childOrderIds).toStrictEqual([ + expected.get('take-profit'), + expected.get('stop-loss'), + ]); + expect(venue.rawTriggers).toHaveLength(0); + }, + ); + + it.each([ + 'account', + 'market', + 'duplicate-client', + 'duplicate-id', + 'unsafe-id', + 'changed-id', + ])( + 'retains the journal for an invalid position protection receipt: %s', + async (invalid) => { + const infra = createMockInfrastructure(); + const { provider, clientInstance, bridge } = buildProvider({ + platformDependencies: infra, + }); + const venue = setupTriggerVenue(clientInstance, bridge); + const oldId = venue.seedTrigger('stop-loss', '80000'); + const read = clientInstance.getActiveOrders.getMockImplementation(); + if (!read) { + throw new Error('missing venue reader'); + } + const removeItem = jest.spyOn(infra.diskCache, 'removeItem'); + let receiptReads = 0; + clientInstance.getActiveOrders.mockImplementation( + async (...args: unknown[]) => { + const response = (await read(...args)) as { + orders: RawTriggerOrder[]; + }; + const rows = response.orders.map((row: RawTriggerOrder) => ({ + ...row, + })); + const created = rows.filter( + (row: RawTriggerOrder) => row.orderIndex !== oldId, + ); + if (created.length > 0) { + receiptReads += 1; + if (invalid === 'account') { + created[0].ownerAccountIndex = 99; + } + if (invalid === 'market') { + created[0].marketIndex = 99; + } + if (invalid === 'unsafe-id') { + created[0].orderIndex = Number.MAX_SAFE_INTEGER + 1; + } + if (invalid === 'duplicate-client') { + rows.push({ ...created[0] }); + } + if (invalid === 'duplicate-id') { + created[1].orderIndex = created[0].orderIndex; + } + if (invalid === 'changed-id' && receiptReads > 1) { + created[0].orderIndex += 50; + } + } + return { ...response, orders: rows }; + }, + ); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain('receipt identity'); + expect(result.childOrderIds).toBeUndefined(); + expect(removeItem).not.toHaveBeenCalledWith( + expect.stringContaining('lighterTpslJournal:'), + ); + }, + ); + + it('returns an empty position protection receipt after removal', async () => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + expect( + await provider.updatePositionTPSL({ symbol: 'BTC' }), + ).toMatchObject({ + success: true, + childOrderIds: [], + }); + expect(venue.rawTriggers).toHaveLength(0); + }); + + it.each(['single', 'pair'] as const)( + 'normalizes explicit partial %s quantities downward without widening', + async (kind) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.030009', + ...(kind === 'pair' + ? { takeProfitPrice: '110000', takeProfitSize: '0.030009' } + : {}), + }); + expect(result.success).toBe(true); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(kind === 'pair' ? ['0.03', '0.03'] : ['0.03']); + }, + ); + + it.each([ + ['below tick', '0.000009', undefined, 'outside the size grid'], + [ + 'exact over-position', + '0.10000000000000000001', + undefined, + 'no greater than the position', + ], + [ + 'unequal normalized pair', + '0.030009', + '0.030010', + 'equal normalized quantities', + ], + ])( + 'rejects partial %s before setup or mutation', + async (_label, size, tpSize, diagnostic) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const prior = venue.seedTrigger('stop-loss', '90000'); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: size, + ...(tpSize + ? { takeProfitPrice: '110000', takeProfitSize: tpSize } + : {}), + }); + expect(result.success).toBe(false); + expect(result.error).toContain(diagnostic); + expect(built.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + prior, + ]); + }, + ); + + it('releases proven-unsent partial signing failure and permits fresh protection intent', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let fail = true; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (fail && call.function === '_signCreateOrder') { + throw new Error('signing interrupted before send'); + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result).toMatchObject({ + success: false, + error: 'signing interrupted before send', + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await built.provider.getPendingManualRecoveries()).toStrictEqual( + [], + ); + fail = false; + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + stopLossSize: '0.04', + }) + ).success, + ).toBe(true); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.04']); + }); + + it.each(['signing failure', 'position drift'] as const)( + 'releases a proven-unsent partial cancel after %s', + async (failure) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const before = venue.rawTriggers.map((row) => ({ ...row })); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if ( + call.function === '_signCancelOrder' && + failure === 'signing failure' + ) { + throw new Error('cancel signing failed before send'); + } + const signed = await execute(call); + if (call.function === '_signCancelOrder') { + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], avgEntryPrice: '100001' }, + ], + }, + ], + }); + } + return signed; + }, + ); + built.clientInstance.sendTx.mockClear(); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result).toMatchObject({ + success: false, + error: + failure === 'signing failure' + ? 'cancel signing failed before send' + : PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers).toStrictEqual(before); + expect(await built.provider.getPendingManualRecoveries()).toStrictEqual( + [], + ); + (built.bridge.execute as jest.Mock).mockImplementation(execute); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + stopLossSize: '0.04', + }) + ).success, + ).toBe(true); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.04']); + }, + ); + + it.each(['takeProfitSize', 'stopLossSize'] as const)( + 'rejects a price-less partial %s without setup', + async (field) => { + const built = buildProvider(); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + [field]: '0.03', + }); + expect(result.success).toBe(false); + expect(result.error).toBe( + 'A partial protection size requires its trigger price', + ); + expect(built.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('rejects a changed persisted partial intent before dispatch', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const read = infra.diskCache.getItem.bind(infra.diskCache); + infra.diskCache.getItem = async (key: string): Promise => { + const raw = await read(key); + if (raw && key.startsWith('lighterTpslJournalOp:')) { + const record = JSON.parse(raw) as { + partialIntent: { orders: (string | number)[][] }; + }; + record.partialIntent.orders[0][2] = '4000'; + return JSON.stringify(record); + } + return raw; + }; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result).toMatchObject({ + success: false, + error: 'Cannot change the persisted partial protection intent', + }); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('replaces a prior linked OCO pair with exact partial coverage', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + takeProfitPrice: '120000', + }) + ).success, + ).toBe(true); + const priorIds = venue.rawTriggers.map((row) => row.orderIndex); + built.calls.length = 0; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + takeProfitPrice: '110000', + stopLossSize: '0.03', + takeProfitSize: '0.03', + }); + expect(result.success).toBe(true); + expect( + built.calls.findIndex((call) => call.function === '_signCancelOrder'), + ).toBeLessThan( + built.calls.findIndex( + (call) => call.function === '_signCreateGroupedOrders', + ), + ); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.03', '0.03']); + expect( + venue.rawTriggers.every((row) => !priorIds.includes(row.orderIndex)), + ).toBe(true); + }); + + it.each( + (['storage', 'signing'] as const).flatMap((boundary) => + (['single', 'pair'] as const).flatMap((kind) => + ( + [ + 'entry drift', + 'account switch', + 'network replacement', + 'unchanged', + ] as const + ).map((change) => ({ boundary, kind, change })), + ), + ), + )( + 'fences partial $kind at held $boundary after $change', + async ({ boundary, kind, change }) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const before = venue.rawTriggers.map((row) => ({ ...row })); + const entered = createDeferred(); + const release = createDeferred(); + if (boundary === 'storage') { + const read = infra.diskCache.getItem.bind(infra.diskCache); + let reads = 0; + infra.diskCache.getItem = async ( + key: string, + ): Promise => { + if (key.startsWith('lighterManagedTpsl:')) { + reads += 1; + } + if (key.startsWith('lighterManagedTpsl:') && reads === 2) { + entered.resolve(); + await release.promise; + } + return read(key); + }; + } else { + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const result = await execute(call); + if (call.function === '_signCancelOrder') { + entered.resolve(); + await release.promise; + } + return result; + }, + ); + } + built.clientInstance.sendTx.mockClear(); + const pending = built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.030009', + ...(kind === 'pair' + ? { takeProfitPrice: '110000', takeProfitSize: '0.030009' } + : {}), + }); + await entered.promise; + if (change === 'entry drift') { + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], avgEntryPrice: '100001' }, + ], + }, + ], + }); + } else if (change === 'account switch') { + built.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000002', + ); + } else if (change === 'network replacement') { + await built.provider.disconnect(); + } + release.resolve(); + const result = await pending; + const mainnet = buildProvider({ + platformDependencies: infra, + isTestnet: false, + registeredKey: '9c'.repeat(40), + }); + expect( + await mainnet.provider.getPendingManualRecoveries(), + ).toStrictEqual([]); + expect(mainnet.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(result.success).toBe(change === 'unchanged'); + expect(result.error === undefined).toBe(change === 'unchanged'); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + change === 'unchanged' ? 2 : 0, + ); + expect( + venue.rawTriggers.filter((row) => + before.some((prior) => prior.orderIndex === row.orderIndex), + ), + ).toStrictEqual(change === 'unchanged' ? [] : before); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual( + change === 'unchanged' + ? Array.from({ length: kind === 'pair' ? 2 : 1 }, () => '0.03') + : before.map((row) => row.initialBaseAmount), + ); + }, + ); + + it.each( + ([1, -1] as const).flatMap((sign) => + (['0.1', '0.03'] as const).map((quantity) => ({ sign, quantity })), + ), + )( + 'uses the raw venue decimal for explicit partial request $quantity with sign $sign before setup', + async ({ sign, quantity }) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const prior = venue.seedTrigger('stop-loss', '90000'); + const before = venue.rawTriggers.map((row) => ({ ...row })); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + position: '0.09999999999999999999', + sign, + }, + ], + }, + ], + }); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: sign === 1 ? '85000' : '115000', + stopLossSize: quantity, + }); + expect(result.success).toBe(quantity === '0.03'); + expect(result.error).toBe( + quantity === '0.1' + ? 'Partial protection quantity must be positive and no greater than the position' + : undefined, + ); + expect(built.calls.length === 0).toBe(quantity === '0.1'); + expect( + venue.rawTriggers.filter((row) => row.orderIndex === prior), + ).toStrictEqual(before); + expect( + venue.rawTriggers + .filter((row) => row.orderIndex !== prior) + .map((row) => row.initialBaseAmount), + ).toStrictEqual(quantity === '0.03' ? ['0.03'] : []); + }, + ); + + it('releases ordinary unsent partial intent without a cleanup venue read masking signing failure', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + built.clientInstance.getActiveOrders.mockRejectedValue( + new Error('venue read unavailable during cleanup'), + ); + throw new Error('original partial signing failure'); + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result.error).toBe('original partial signing failure'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await built.provider.getPendingManualRecoveries()).toStrictEqual( + [], + ); + const index = JSON.parse( + (await infra.diskCache.getItem('lighterTpslJournalIndex:testnet')) ?? + '[]', + ) as string[]; + expect(index).toStrictEqual([]); + }); + + it.each(['single', 'pair'] as const)( + 'retains dispatched cancellation when held partial %s creation observes drift', + async (kind) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const entered = createDeferred(); + const release = createDeferred(); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const signed = await execute(call); + if ( + call.function === '_signCreateOrder' || + call.function === '_signCreateGroupedOrders' + ) { + entered.resolve(); + await release.promise; + } + return signed; + }, + ); + built.clientInstance.sendTx.mockClear(); + const pending = built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + ...(kind === 'pair' + ? { takeProfitPrice: '110000', takeProfitSize: '0.03' } + : {}), + }); + await entered.promise; + expect(venue.rawTriggers).toStrictEqual([]); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], avgEntryPrice: '100001' }], + }, + ], + }); + release.resolve(); + expect((await pending).error).toBe( + PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + const rows = await built.provider.getPendingManualRecoveries(); + expect(rows).toHaveLength(1); + expect( + rows[0].partialIntent?.legs.map((leg) => leg.size), + ).toStrictEqual( + Array.from({ length: kind === 'pair' ? 2 : 1 }, () => '0.03'), + ); + expect(venue.rawTriggers).toStrictEqual([]); + }, + ); + + it.each([false, true])( + 'preserves the final position guard when unsent cleanup fails: %s', + async (cleanupFails) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const priorProtection = JSON.stringify(venue.rawTriggers); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const signed = await execute(call); + if (call.function === '_signCancelOrder') { + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], avgEntryPrice: '100001' }, + ], + }, + ], + }); + } + return signed; + }, + ); + const remove = infra.diskCache.removeItem.bind(infra.diskCache); + infra.diskCache.removeItem = async (key: string): Promise => { + if (cleanupFails && key.startsWith('lighterTpslJournal')) { + throw new Error('cleanup storage unavailable'); + } + return remove(key); + }; + built.clientInstance.sendTx.mockClear(); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result.error).toBe(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(JSON.stringify(venue.rawTriggers)).toBe(priorProtection); + const rows = await built.provider.getPendingManualRecoveries(); + expect(rows).toHaveLength(cleanupFails ? 1 : 0); + const writes = (infra.diskCache.setItem as jest.Mock).mock.calls as [ + string, + string, + ][]; + const journalWrites = writes.filter(([name]) => + name.startsWith('lighterTpslJournalOp:'), + ); + const journal = JSON.parse( + journalWrites[journalWrites.length - 1][1], + ) as { operationId: string }; + const expectedId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 7, 'BTC', journal.operationId])}`; + expect(rows.map((row) => row.recoveryId)).toStrictEqual( + cleanupFails ? [expectedId] : [], + ); + const ledger = JSON.parse( + (await infra.diskCache.getItem('lighterNonceLedger:testnet:28:7')) ?? + '{}', + ) as { entries: { owner: string | null }[] }; + expect( + ledger.entries.filter((entry) => entry.owner === journal.operationId), + ).toHaveLength(cleanupFails ? 1 : 0); + const lastJournalWrite = journalWrites[journalWrites.length - 1]; + expect(await infra.diskCache.getItem(lastJournalWrite[0])).toBe( + cleanupFails ? lastJournalWrite[1] : null, + ); + expect( + (await built.provider.getPendingManualRecoveries()).map( + (row) => row.recoveryId, + ), + ).toStrictEqual(rows.map((row) => row.recoveryId)); + }, + ); + + it('keeps an unretirable unsent partial operation selectable and preserves its original failure', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + throw new Error('original signing failure'); + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }, + ); + const remove = infra.diskCache.removeItem.bind(infra.diskCache); + infra.diskCache.removeItem = async (key: string): Promise => { + if (key.startsWith('lighterTpslJournal')) { + throw new Error('cleanup storage unavailable'); + } + return remove(key); + }; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result.error).toBe('original signing failure'); + const rows = await built.provider.getPendingManualRecoveries(); + expect(rows).toHaveLength(1); + expect(rows[0].recoveryId).toBeDefined(); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it.each(['take-profit', 'stop-loss', 'pair'] as const)( + 'places fixed partial %s protection without expanding to the full position', + async (kind) => { + const { provider, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + ...(kind === 'stop-loss' + ? {} + : { takeProfitPrice: '110000', takeProfitSize: '0.03' }), + ...(kind === 'take-profit' + ? {} + : { stopLossPrice: '85000', stopLossSize: '0.03' }), + }); + expect(result.success).toBe(true); + expect(result.childOrderIds).toHaveLength(kind === 'pair' ? 2 : 1); + expect(venue.rawTriggers).toHaveLength(kind === 'pair' ? 2 : 1); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(kind === 'pair' ? ['0.03', '0.03'] : ['0.03']); + }, + ); + + it('cancels managed protection before creating partial coverage and preserves unrelated orders', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const unrelated = venue.seedTrigger('stop-loss', '82000'); + const unrelatedOrder = venue.rawTriggers.find( + (row) => row.orderIndex === unrelated, + ); + if (unrelatedOrder) { + unrelatedOrder.initialBaseAmount = '0.02'; + unrelatedOrder.remainingBaseAmount = '0.02'; + } + venue.events.length = 0; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result.success).toBe(true); + expect(venue.events.indexOf('cancel')).toBeLessThan( + venue.events.indexOf('create'), + ); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.02', '0.03']); + expect( + (await built.provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + unrelated, + ]); + }); + + it.each([1, -1])( + 'normalizes independent coverage down for position sign %s and removes exact owned legs', + async (sign) => { + const built = buildProvider(); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { ...ACCOUNT, positions: [{ ...ACCOUNT.positions[0], sign }] }, + ], + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const unrelated = venue.seedTrigger('stop-loss', '82000'); + const row = venue.rawTriggers[0]; + row.initialBaseAmount = '0.02'; + row.remainingBaseAmount = '0.02'; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.030009', + stopLossSize: '0.040009', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(true); + expect( + result.positionProtection?.legs.map((leg) => [ + leg.requestedSize, + leg.normalizedSize, + leg.status, + ]), + ).toStrictEqual([ + ['0.030009', '0.03', 'resting'], + ['0.040009', '0.04', 'resting'], + ]); + expect( + built.calls + .filter((call) => call.function === '_signCreateOrder') + .map((call) => call.params[5]), + ).toStrictEqual([sign === 1 ? 1 : 0, sign === 1 ? 1 : 0]); + expect( + (await built.provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect( + venue.rawTriggers.map((order) => order.orderIndex), + ).toStrictEqual([unrelated]); + }, + ); + + it.each(['0', '-0.01', '0.00000001', '0.100001', 'NaN', '0.03BTC'])( + 'rejects independent quantity %s before setup or cancellation', + async (size) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const prior = venue.seedTrigger('stop-loss', '90000'); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: size, + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(false); + expect(built.calls).toHaveLength(0); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + prior, + ]); + }, + ); + + it.each(['quantity', 'linkage', 'client-id'] as const)( + 'rejects tampered independent %s before a create dispatch', + async (field) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const read = infra.diskCache.getItem.bind(infra.diskCache); + infra.diskCache.getItem = async ( + key: string, + ): Promise => { + const raw = await read(key); + if (!raw || !key.startsWith('lighterTpslJournalOp:')) { + return raw; + } + const record = JSON.parse(raw) as { + partialIntent: { linkage: string; orders: (string | number)[][] }; + }; + if (field === 'quantity') { + record.partialIntent.orders[0][2] = '6000'; + } else if (field === 'linkage') { + record.partialIntent.linkage = 'oco'; + } else { + record.partialIntent.orders[0][1] = + record.partialIntent.orders[1][1]; + } + return JSON.stringify(record); + }; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(false); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('preserves the accepted independent first leg if persisting the sibling fails', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const write = infra.diskCache.setItem.bind(infra.diskCache); + infra.diskCache.setItem = async ( + key: string, + raw: string, + ): Promise => { + if (key.startsWith('lighterTpslJournalOp:')) { + const record = JSON.parse(raw) as { attempts: { kind: string }[] }; + if ( + record.attempts.filter((attempt) => attempt.kind === 'create') + .length === 2 + ) { + throw new Error('second-leg disk failure'); + } + } + return await write(key, raw); + }; + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result).toMatchObject({ + success: false, + error: 'second-leg disk failure', + }); + expect(result.positionProtection?.legs[0].status).toBe('resting'); + expect(result.positionProtection?.legs[1].clientOrderId).toBeUndefined(); + expect(venue.rawTriggers).toHaveLength(1); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + infra.diskCache.setItem = write; + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(1); + }); + + it.each([1, 2])( + 'preserves exact independent outcomes when leg %s is rejected or unknown', + async (failedLeg) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + let creates = 0; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + creates += 1; + if (creates === failedLeg) { + venue.setCreateTerminal('canceled'); + } + } + return await execute(call); + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(false); + expect(creates).toBe(failedLeg); + expect(result.positionProtection?.legs[failedLeg - 1]).toMatchObject({ + status: 'canceled', + clientOrderId: expect.any(String) as string, + orderId: expect.any(String) as string, + }); + expect(result.positionProtection?.legs[0].status).toBe( + failedLeg === 2 ? 'resting' : 'canceled', + ); + expect( + result.positionProtection?.legs[1].clientOrderId === undefined, + ).toBe(failedLeg === 1); + const obligations = await built.provider.getPendingManualRecoveries(); + expect(obligations).toHaveLength(1); + expect(obligations[0].partialIntent).toMatchObject({ + version: 2, + linkage: 'independent', + }); + expect(creates).toBe(failedLeg); + expect(venue.rawTriggers).toHaveLength(failedLeg - 1); + }, + ); + + it.each([1, 2])( + 'retains independent accepted identities after a lost response for leg %s', + async (failedLeg) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + let creates = 0; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + creates += 1; + if (creates === failedLeg) { + venue.failResponseOnce(14); + } + } + return await execute(call); + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(false); + expect(creates).toBe(failedLeg); + expect(result.positionProtection?.legs[failedLeg - 1]).toMatchObject({ + status: 'resting', + clientOrderId: expect.any(String) as string, + orderId: expect.any(String) as string, + }); + await built.provider.getPendingManualRecoveries(); + expect(creates).toBe(failedLeg); + expect(venue.rawTriggers).toHaveLength(failedLeg); + }, + ); + + it.each([7, 19])( + 'keeps an accepted independent first leg across restart on key %s until explicit cleanup', + async (apiKeyIndex) => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + venue.failResponseOnce(14); + const request = { + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent' as const, + }; + expect((await first.provider.updatePositionTPSL(request)).success).toBe( + false, + ); + const restarted = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + apiKeyIndex, + }); + const recovered = setupTriggerVenue( + restarted.clientInstance, + restarted.bridge, + { apiKeyIndex }, + ); + recovered.rawTriggers.push(...venue.rawTriggers); + recovered.rawInactive.push(...venue.rawInactive); + for (const [hash, tx] of venue.landedTxs) { + recovered.landedTxs.set(hash, tx); + } + recovered.setVenueNonce(venue.getVenueNonce()); + recovered.setNextIndex(venue.getNextIndex()); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], + }); + restarted.clientInstance.getTx.mockImplementation( + async (hash: string) => { + const tx = recovered.landedTxs.get(hash); + return tx + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: venue.landedTxs.has(hash) ? 7 : apiKeyIndex, + nonce: tx.nonce, + status: tx.status, + } + : null; + }, + ); + expect( + (await restarted.provider.updatePositionTPSL(request)).success, + ).toBe(false); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateOrder', + ), + ).toHaveLength(0); + expect(recovered.rawTriggers).toHaveLength(1); + const pending = await restarted.provider.getPendingManualRecoveries(); + expect(pending).toHaveLength(1); + const { recoveryId } = pending[0]; + if (!recoveryId) { + throw new Error('Expected original independent recovery'); + } + const cleanup = await restarted.provider.resolveRecoveryProtection({ + symbol: 'BTC', + recoveryId, + }); + expect(cleanup.success).toBe(true); + expect( + 'childOrderIds' in cleanup ? cleanup.childOrderIds : undefined, + ).toStrictEqual([]); + expect(recovered.rawTriggers).toHaveLength(0); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateOrder', + ), + ).toHaveLength(0); + }, + ); + + it('fences a signed independent sibling when the first leg fills before its dispatch', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as (call: LighterWasmCall) => Promise; + let creates = 0; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + const result = await execute(call); + if (call.function === '_signCreateOrder' && (creates += 1) === 2) { + const [first] = venue.rawTriggers.splice(0, 1); + venue.rawInactive.push({ + ...first, + status: 'filled', + remainingBaseAmount: '0', + }); + } + return result; + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(false); + expect(creates).toBe(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect(result.positionProtection?.legs[0].status).toBe('filled'); + expect(result.positionProtection?.legs[1].clientOrderId).toBeUndefined(); + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(1); + }); + + it('never submits the independent sibling after the first trigger fills', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.setCreateTerminal('filled'); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result).toMatchObject({ + success: false, + error: + 'Independent protection first leg executed; the stale sibling was not sent', + }); + expect(result.positionProtection?.legs[0]).toMatchObject({ + status: 'filled', + orderId: expect.any(String) as string, + }); + expect(result.positionProtection?.legs[1].clientOrderId).toBeUndefined(); + await built.provider.getPendingManualRecoveries(); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + }); + + it.each(['resize', 'side', 'account', 'network'] as const)( + 'stops the independent sibling after %s changes', + async (change) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const send = built.clientInstance.sendTx.getMockImplementation() as ( + txType: number, + txInfo: string, + ) => Promise; + built.clientInstance.sendTx.mockImplementation( + async (txType: number, txInfo: string) => { + const result = await send(txType, txInfo); + if (txType === 14) { + if (change === 'account') { + built.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000002', + ); + } else if (change === 'network') { + await built.provider.disconnect(); + } else { + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + ...(change === 'resize' + ? { position: '0.08' } + : { sign: -1 }), + }, + ], + }, + ], + }); + } + } + return result; + }, + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + takeProfitSize: '0.03', + stopLossSize: '0.04', + partialPairLinkage: 'independent', + }); + expect(result.success).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect(venue.rawTriggers).toHaveLength(1); + expect( + result.positionProtection?.legs[1].clientOrderId, + ).toBeUndefined(); + }, + ); + + it.each([ + { takeProfitSize: '0.03', stopLossSize: '0.04' }, + { takeProfitSize: '0.03', stopLossSize: '0.03' }, + { takeProfitSize: '0.03' }, + { stopLossSize: '0.04' }, + ])( + 'creates explicitly independent protection with captured sizes %j', + async (sizes) => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const request = { + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + partialPairLinkage: 'independent' as const, + ...sizes, + }; + const result = await built.provider.updatePositionTPSL(request); + expect(result.success).toBe(true); + expect(result.childOrderIds).toHaveLength(2); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual([ + sizes.takeProfitSize ?? '0.1', + sizes.stopLossSize ?? '0.1', + ]); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(2); + expect( + built.calls.filter( + (call) => call.function === '_signCreateGroupedOrders', + ), + ).toHaveLength(0); + }, + ); + + it.each([ + { takeProfitSize: '0.03', stopLossSize: '0.04' }, + { takeProfitSize: '0.1' }, + { stopLossSize: '0.1' }, + ])( + 'rejects unequal or mixed partial OCO quantities %j before mutation', + async (sizes) => { + const built = buildProvider(); + setupTriggerVenue(built.clientInstance, built.bridge); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '85000', + ...sizes, + }); + expect(result).toMatchObject({ + success: false, + error: 'Partial OCO protection requires equal normalized quantities', + }); + expect(built.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [false, 7, false], + [false, 7, true], + [true, 7, false], + [true, 7, true], + [true, 19, false], + [true, 19, true], + ] as const)( + 'never recreates interrupted partial intent after restart, including identical reopened position=%s slot=%s independent=%s', + async (reopened, apiKeyIndex, independent) => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + expect( + ( + await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const execute = ( + first.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (first.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + throw new Error('process interrupted before partial signing'); + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }, + ); + expect( + ( + await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + ...(independent + ? { + takeProfitPrice: '110000', + takeProfitSize: '0.03', + partialPairLinkage: 'independent' as const, + } + : {}), + }) + ).success, + ).toBe(false); + expect(venue.rawTriggers).toHaveLength(0); + const recorded = await first.provider.getPendingManualRecoveries(); + expect(recorded).toHaveLength(1); + expect(recorded[0].partialIntent?.legs[0].size).toBe('0.03'); + const second = buildProvider({ + apiKeyIndex, + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const recoveredVenue = setupTriggerVenue( + second.clientInstance, + second.bridge, + { apiKeyIndex }, + ); + second.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], + }); + second.clientInstance.getTx.mockImplementation(async (hash: string) => { + const tx = recoveredVenue.landedTxs.get(hash); + return tx + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: venue.landedTxs.has(hash) ? 7 : apiKeyIndex, + nonce: tx.nonce, + status: tx.status, + } + : null; + }); + recoveredVenue.rawInactive.push(...venue.rawInactive); + for (const [hash, tx] of venue.landedTxs) { + recoveredVenue.landedTxs.set(hash, tx); + } + recoveredVenue.setVenueNonce(venue.getVenueNonce()); + recoveredVenue.setNextIndex(venue.getNextIndex()); + let observedFlat: number | undefined; + if (reopened) { + second.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [] }], + }); + observedFlat = (await second.provider.getPositions()).length; + // Even after observing closure, identical reopened values cannot authorize replay. + second.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: ACCOUNT.positions.map((position) => ({ + ...position, + })), + }, + ], + }); + } + expect(observedFlat).toBe(reopened ? 0 : undefined); + const retry = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(retry.success).toBe(false); + expect( + second.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + const recoveries = await second.provider.getPendingManualRecoveries(); + expect(recoveries).toHaveLength(1); + const { recoveryId } = recoveries[0]; + if (!recoveryId) { + throw new Error('Expected a selectable recovery ID'); + } + const resolved = await second.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(resolved.error).toBeUndefined(); + expect(resolved.success).toBe(true); + expect( + recoveredVenue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.04']); + }, + ); + + it.each([ + 'source-link', + 'prepared', + 'pending', + 'remove-failure', + 'cas-conflict', + ] as const)( + 'retains an older-slot zero-attempt partial journal with %s', + async (condition) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + throw new Error('interrupted'); + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }, + ); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }) + ).success, + ).toBe(false); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + // Restore the exact durable pre-signing snapshot to model process death, + // including a restart before the live error cleanup could run. + const writes = (infra.diskCache.setItem as jest.Mock).mock.calls as [ + string, + string, + ][]; + const pointerWrite = writes.find( + ([name]) => name === `lighterTpslJournal:testnet:${key}`, + ); + const payloadWrite = writes.find(([name]) => + name.startsWith(`lighterTpslJournalOp:testnet:${key}:`), + ); + if (!pointerWrite || !payloadWrite) { + throw new Error('Expected durable pre-dispatch snapshot'); + } + await infra.diskCache.setItem(...pointerWrite); + await infra.diskCache.setItem(...payloadWrite); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([key]), + ); + const payload = JSON.parse(payloadWrite[1]) as { + operationId: string; + attempts: unknown[]; + sourceRecoveryOperationId?: string; + sourceRecoverySettlementKey?: string; + }; + expect(payload.attempts).toStrictEqual([]); + const sourceKey = `${ACCOUNT.l1Address.toLowerCase()}:28:3:BTC`; + const successorKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + if (condition === 'source-link') { + payload.sourceRecoveryOperationId = 'original-source'; + payload.sourceRecoverySettlementKey = sourceKey; + await infra.diskCache.setItem( + payloadWrite[0], + JSON.stringify(payload), + ); + } + const transferKey = `lighterTpslSuccessor:testnet:${key}:${payload.operationId}`; + if (condition === 'prepared' || condition === 'pending') { + await infra.diskCache.setItem( + transferKey, + JSON.stringify({ + version: 1, + sourceSettlementKey: key, + sourceOperationId: payload.operationId, + successorSettlementKey: successorKey, + successorOperationId: 'retained-successor', + state: condition, + ownedOrderIds: [], + }), + ); + } + const retainedPayload = await infra.diskCache.getItem(payloadWrite[0]); + const retainedTransfer = await infra.diskCache.getItem(transferKey); + if (condition === 'remove-failure') { + const remove = infra.diskCache.removeItem.bind(infra.diskCache); + infra.diskCache.removeItem = async (name: string): Promise => { + if (name === payloadWrite[0]) { + throw new Error('older-slot cleanup unavailable'); + } + return remove(name); + }; + } + const restarted = buildProvider({ + apiKeyIndex: 19, + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex: 19, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], + }); + const venue = setupTriggerVenue( + restarted.clientInstance, + restarted.bridge, + ); + const before = await restarted.provider.getPendingManualRecoveries(); + expect(before).toHaveLength(1); + const expectedId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 7, 'BTC', payload.operationId])}`; + expect(before[0].recoveryId).toBe(expectedId); + const replacementPayload = JSON.stringify({ + ...payload, + operationId: 'newer-operation', + }); + const replacementKey = `lighterTpslJournalOp:testnet:${key}:newer-operation`; + if (condition === 'cas-conflict') { + const get = infra.diskCache.getItem.bind(infra.diskCache); + let replaced = false; + infra.diskCache.getItem = async ( + name: string, + ): Promise => { + if (name === transferKey && !replaced) { + replaced = true; + // Another owner wins the pointer after the retirement candidate was read. + await infra.diskCache.setItem(replacementKey, replacementPayload); + await infra.diskCache.setItem( + pointerWrite[0], + JSON.stringify({ + pointerVersion: 1, + operationId: 'newer-operation', + }), + ); + } + return get(name); + }; + } + const result = await restarted.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(result.error).toBe( + 'Partial protection requires explicit recovery resolution by recovery ID', + ); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers).toStrictEqual([]); + expect( + (await restarted.provider.getPendingManualRecoveries()).map( + (row) => row.recoveryId, + ), + ).toStrictEqual([ + condition === 'cas-conflict' + ? `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 7, 'BTC', 'newer-operation'])}` + : expectedId, + ]); + expect(await infra.diskCache.getItem(payloadWrite[0])).toBe( + condition === 'cas-conflict' ? null : retainedPayload, + ); + expect(await infra.diskCache.getItem(replacementKey)).toBe( + condition === 'cas-conflict' ? replacementPayload : null, + ); + expect(await infra.diskCache.getItem(transferKey)).toBe( + retainedTransfer, + ); + expect( + await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${successorKey}`, + ), + ).toBeNull(); + }, + ); + + it.each( + ([7, 19] as const).flatMap((apiKeyIndex) => + (['valid', 'zero', 'wrong-side'] as const).map((condition) => ({ + apiKeyIndex, + condition, + })), + ), + )( + 'loads a pre-dispatch partial journal with $condition intent safely on slot $apiKeyIndex', + async ({ condition, apiKeyIndex }) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const execute = ( + built.bridge.execute as jest.Mock + ).getMockImplementation() as ( + call: LighterWasmCall, + ) => Promise; + (built.bridge.execute as jest.Mock).mockImplementation( + async (call: LighterWasmCall) => { + if (call.function === '_signCreateOrder') { + throw new Error('interrupted'); + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }, + ); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }) + ).success, + ).toBe(false); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + // Restore the exact durable pre-signing snapshot to model process death, + // including a restart before the live error cleanup could run. + const writes = (infra.diskCache.setItem as jest.Mock).mock.calls as [ + string, + string, + ][]; + const pointerWrite = writes.find( + ([name]) => name === `lighterTpslJournal:testnet:${key}`, + ); + const payloadWrite = writes.find(([name]) => + name.startsWith(`lighterTpslJournalOp:testnet:${key}:`), + ); + if (!pointerWrite || !payloadWrite) { + throw new Error('Expected durable pre-dispatch snapshot'); + } + await infra.diskCache.setItem(...pointerWrite); + await infra.diskCache.setItem(...payloadWrite); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([key]), + ); + const payloadKey = payloadWrite[0]; + const payload = JSON.parse(payloadWrite[1]) as { + version: number; + attempts: unknown[]; + partialIntent: { orders: (string | number)[][] }; + }; + expect(payload.version).toBe(5); + expect(payload.attempts).toHaveLength(0); + if (condition !== 'valid') { + payload.partialIntent.orders[0][condition === 'zero' ? 2 : 4] = + condition === 'zero' ? '0' : 0; + await infra.diskCache.setItem(payloadKey, JSON.stringify(payload)); + } + const restarted = buildProvider({ + apiKeyIndex, + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const pending = restarted.provider.getPendingManualRecoveries(); + const outcome = await pending.then( + (rows) => ({ count: rows.length, error: undefined }), + (error: unknown) => ({ count: undefined, error: String(error) }), + ); + expect(outcome.count).toBe(condition === 'valid' ? 1 : undefined); + expect(outcome.error?.includes('journal') ?? false).toBe( + condition !== 'valid', + ); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + const venue = setupTriggerVenue( + restarted.clientInstance, + restarted.bridge, + ); + const renewed = await restarted.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(renewed.success).toBe(condition === 'valid'); + const index = JSON.parse( + (await infra.diskCache.getItem('lighterTpslJournalIndex:testnet')) ?? + '[]', + ) as string[]; + expect(index.includes(key)).toBe(condition !== 'valid'); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(condition === 'valid' ? ['0.04'] : []); + }, + ); + + it('refuses partial creation when position changes during cancellation without a caller precondition', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const send = built.clientInstance.sendTx.getMockImplementation() as ( + type: number, + info: string, + ) => Promise; + built.clientInstance.sendTx.mockImplementation( + async (type: number, info: string) => { + const response = await send(type, info); + if (type === 15) { + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], sign: -1 }], + }, + ], + }); + } + return response; + }, + ); + built.calls.length = 0; + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }) + ).success, + ).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(0); + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(1); + }); + + it('directs late partial terminal failure to its exact pending recovery obligation', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + const read = + built.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + let replacementReads = 0; + built.clientInstance.getActiveOrders.mockImplementation(async () => { + const index = venue.rawTriggers.findIndex( + (row) => row.triggerPrice === '85000', + ); + if (index >= 0) { + replacementReads += 1; + } + if (index >= 0 && replacementReads === 2) { + const [row] = venue.rawTriggers.splice(index, 1); + venue.rawInactive.push({ ...row, status: 'canceled' }); + } + return read(); + }); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(result.success).toBe(false); + expect(result.error).toContain( + 'call resolveRecoveryProtection with its exact recovery ID', + ); + const rows = await built.provider.getPendingManualRecoveries(); + expect(rows).toHaveLength(1); + expect(rows[0].recoveryId).toBeDefined(); + expect(rows[0].partialIntent?.legs[0].size).toBe('0.03'); + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(retry.success).toBe(false); + expect(venue.rawTriggers).toStrictEqual([]); + }); + + it('retains explicit partial recovery after a terminal replacement rejection', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + venue.setCreateTerminal('canceled'); + const failed = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(failed.success).toBe(false); + expect(venue.rawTriggers).toHaveLength(0); + const recoveries = await built.provider.getPendingManualRecoveries(); + expect(recoveries).toHaveLength(1); + expect(recoveries[0].reason).toContain('current trading key'); + expect(recoveries[0].partialIntent?.legs[0].size).toBe('0.03'); + const before = built.calls.length; + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(retry.error).toBe( + 'Prior partial protection requires resolveRecoveryProtection with its exact pending recovery ID and fresh current-position intent', + ); + expect( + built.calls + .slice(before) + .filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }); + + it.each([false, true])( + 'does not create partial protection while an exact cancellation is ambiguous independent=%s', + async (independent) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '90000', + }) + ).success, + ).toBe(true); + built.calls.length = 0; + venue.failResponseOnce(15); + built.clientInstance.getTx.mockResolvedValue(null); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + ...(independent + ? { + takeProfitPrice: '110000', + takeProfitSize: '0.04', + partialPairLinkage: 'independent' as const, + } + : {}), + }); + expect(result.success).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + }, + ); + + it('reconciles an ambiguous partial create without replay or caller quantity substitution', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.failResponseOnce(14); + const first = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + stopLossSize: '0.03', + }); + expect(first.success).toBe(false); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.03']); + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + stopLossSize: '0.04', + }); + expect(retry).toMatchObject({ + success: false, + error: + 'Prior partial protection settled; re-read orders before submitting a new intent', + }); + expect(await built.provider.getPendingManualRecoveries()).toStrictEqual( + [], + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + venue.rawTriggers.map((row) => row.initialBaseAmount), + ).toStrictEqual(['0.03']); + }); + + it.each(['0', '-0.01', '0.00000001', '0.11', 'NaN', '0.03BTC'])( + 'rejects invalid partial quantity %s before mutation', + async (size) => { + const { provider, clientInstance, bridge, calls } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + const prior = venue.seedTrigger('stop-loss', '90000'); + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + takeProfitSize: size, + }); + expect(result.success).toBe(false); + expect(result.error).toContain( + size === '0.00000001' + ? 'outside the size grid' + : 'positive and no greater than the position', + ); + expect(calls).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers.map((row) => row.orderIndex)).toStrictEqual([ + prior, + ]); + }, + ); + + 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.each(['unknown', 'accepted'] as const)( + 'retains live ownership after restart with a saved never-landed marker and %s outcome', + async (outcome) => { + const infra = createMockInfrastructure(); + const first = buildProvider({ platformDependencies: infra }); + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + const execute = jest.spyOn(first.bridge, 'execute'); + const sign = execute.getMockImplementation(); + execute.mockImplementation(async (call: LighterWasmCall) => { + if (call.function === '_signCancelOrder') { + throw new Error('process died before old cancel'); + } + if (!sign) { + throw new Error('Test signer missing'); + } + return await sign(call); + }); + expect( + ( + await first.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(false); + const baseKey = `lighterTpslJournal:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const pointer = JSON.parse( + (await infra.diskCache.getItem(baseKey)) ?? '', + ) as { operationId: string }; + const payloadKey = `lighterTpslJournalOp:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC:${pointer.operationId}`; + const payload = JSON.parse( + (await infra.diskCache.getItem(payloadKey)) ?? '', + ) as { + attempts: { + kind: string; + clientIds: number[]; + outcome: string; + neverLanded?: boolean; + }[]; + }; + const create = payload.attempts.find( + (attempt) => attempt.kind === 'create', + ); + if (!create) { + throw new Error('Test create journal missing'); + } + create.neverLanded = true; + create.outcome = outcome; + await infra.diskCache.setItem(payloadKey, JSON.stringify(payload)); + killProvider(first); + + const second = buildProvider({ platformDependencies: infra }); + const venueB = setupTriggerVenue(second.clientInstance, second.bridge); + venueB.setVenueNonce(venueA.getVenueNonce()); + venueB.setNextIndex(venueA.getNextIndex()); + venueB.rawTriggers.push( + ...venueA.rawTriggers.map((row) => ({ ...row })), + ); + for (const [hash, landed] of venueA.landedTxs) { + venueB.landedTxs.set(hash, landed); + } + const storedAttempts: Record[][] = []; + const disk = jest.mocked(infra.diskCache); + const write = disk.setItem.getMockImplementation(); + disk.setItem.mockImplementation(async (key: string, value: string) => { + if (key.startsWith('lighterTpslJournalOp:')) { + storedAttempts.push( + (JSON.parse(value) as { attempts: Record[] }) + .attempts, + ); + } + return write?.(key, value); + }); + await second.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + if ((await infra.diskCache.getItem(payloadKey)) === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(await infra.diskCache.getItem(payloadKey)).toBeNull(); + expect(storedAttempts.length).toBeGreaterThan(0); + expect( + storedAttempts.flat().every((attempt) => !('neverLanded' in attempt)), + ).toBe(true); + expect(venueB.rawTriggers).toHaveLength(1); + const key = `lighterManagedTpsl:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:BTC`; + const owned = JSON.parse( + (await infra.diskCache.getItem(key)) ?? '', + ) as { + orders: { clientId: string; orderId: string | null }[]; + }; + expect(owned.orders).toContainEqual( + expect.objectContaining({ + clientId: String(create.clientIds[0]), + orderId: String(venueB.rawTriggers[0].orderIndex), + }), + ); + second.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.2' }], + }, + ], + }); + const resized = await second.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '84000', + }); + expect(resized.error).toBeUndefined(); + expect(resized.success).toBe(true); + expect(venueB.rawTriggers).toHaveLength(1); + }, + ); + + it('preserves original-slot journals while their unresolved dispatch blocks another slot', 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 the slot-7 journal; its unresolved dispatch blocks new writes. + 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).toContain('unresolved outcome'); + expect(other.success).toBe(false); + // 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 beyond the old ten-page traversal limit. + for (let filler = 0; filler < 1120; 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).toBeGreaterThan(10); + expect(inactiveCalls).toBeLessThanOrEqual(15); + expect(deepVenue.rawTriggers).toHaveLength(1); + expect(deepVenue.rawTriggers[0].triggerPrice).toBe('86000'); + }); + + it('fails closed when inactive-history pagination repeats a cursor', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.setCreateTerminal('filled'); + venue.failResponseOnce(14); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(false); + venue.setCreateTerminal('none'); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [ + { + ...venue.rawInactive[0], + orderIndex: 999999, + clientOrderIndex: 999999, + }, + ], + nextCursor: 'repeated', + }); + + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('repeated cursor'); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(2); + }); + + it('enforces the inactive-history bound from actual returned rows', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.setCreateTerminal('filled'); + venue.failResponseOnce(14); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(false); + venue.setCreateTerminal('none'); + const fillerOrder = { + ...venue.rawInactive[0], + status: 'canceled', + }; + const rows = (count: number, offset: number): RawTriggerOrder[] => + Array.from({ length: count }, (_entry, index) => ({ + ...fillerOrder, + orderIndex: offset + index, + clientOrderIndex: offset + index, + })); + built.clientInstance.getInactiveOrders + .mockResolvedValueOnce({ + code: 200, + orders: rows(6000, 100000), + nextCursor: 'second', + }) + .mockResolvedValueOnce({ + code: 200, + orders: rows(4001, 200000), + nextCursor: 'third', + }); + + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('10001 rows'); + expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(2); + }); + + it('fails closed when inactive history advances without returning rows', async () => { + const built = buildProvider(); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.setCreateTerminal('filled'); + venue.failResponseOnce(14); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(false); + venue.setCreateTerminal('none'); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + nextCursor: 'advanced', + }); + + const retry = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }); + expect(retry.success).toBe(false); + expect(retry.error).toContain('advanced without returning rows'); + }); + + 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('lighterTpslJournalOp:')) { + journalWrites += 1; + // The stable pointer is written once; test the payload update. + // 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; } - return response; - }, - ); - let pendingCreateGate: Promise | null = null; - let releaseCreateGate = (): void => undefined; - let signalGateEntered = (): void => undefined; - const gateEntered = new Promise((resolve) => { - signalGateEntered = resolve; + 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); }); - const armCreateGate = (): Promise => { - pendingCreateGate = new Promise((resolve) => { - releaseCreateGate = resolve; + + 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; }); - 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; + (infra.diskCache.getItem as jest.Mock).mockImplementation( + async (key: string) => { + if (key.startsWith('lighterTpslJournalIndex:')) { + indexReads += 1; + if (gateArmed) { + gateArmed = false; + await indexGate; + } } - 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; + 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; } - 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]), - }); + 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 }; } - 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]), + }, + ); + 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'); }); - return result; } + } + (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 ( - [ - '_signWithdraw', - '_signUpdateMargin', - '_signUpdateLeverage', - '_signChangePubKey', - ].includes(call.function) + venueB.rawTriggers.length === 0 && + journalKeysOf(disk).length === 0 ) { - const result = (await realImplementation(call)) as { - txHash?: string; - txInfo?: string; - }; - let wireNonce: number | undefined; - try { - const parsed = JSON.parse(result.txInfo ?? '') as Record< - string, - unknown - >; - wireNonce = - typeof parsed.Nonce === 'number' ? parsed.Nonce : undefined; - } catch { - wireNonce = undefined; - } - if (typeof result.txHash === 'string' && wireNonce !== undefined) { - stagedGenerics.push({ txHash: result.txHash, nonce: wireNonce }); - } - return result; + break; } - 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, - }; - }; + 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, + ); + }); - 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' }], - }, - ], + 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', }); - // 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', + 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, ); - const tpsl = await provider.updatePositionTPSL({ + 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(tpsl.success).toBe(false); - expect(tpsl.error).toContain('Invalid Lighter venue data'); - const close = await provider.closePosition({ + 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', - currentPrice: 100000, + stopLossPrice: '85000', }); - expect(close.success).toBe(false); - expect(close.error).toContain('Invalid Lighter venue data'); - const closeValidation = await provider.validateClosePosition({ + 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', - currentPrice: 100000, + stopLossPrice: '86000', }); - expect(closeValidation.isValid).toBe(false); - expect(closeValidation.error).toContain('Invalid Lighter venue data'); - expect(calls).toHaveLength(0); + 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('two concurrent replacements serialize: the second cannot snapshot while the first transition is mid-flight', async () => { - const { provider, calls, clientInstance, bridge } = buildProvider(); + 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'); - // 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({ + // 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', - stopLossPrice: '85000', + takeProfitPrice: '111000', + stopLossPrice: '81000', }); - await gateEntered; - const secondPromise = provider.updatePositionTPSL({ + 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: '86000', + stopLossPrice: '84000', }); - 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', - ]); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await provider.getPendingManualRecoveries()).toHaveLength(0); }); - it('replacement vs concurrent remove serializes: the remove sees and clears the fresh protection', async () => { + 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('stop-loss', '80000'); - let accountReads = 0; - let signalSecondPreflight = (): void => undefined; - const secondPreflightDone = new Promise((resolve) => { - signalSecondPreflight = resolve; + 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', }); - const realGetAccount = - clientInstance.getAccountByIndex.getMockImplementation() as () => Promise; - clientInstance.getAccountByIndex.mockImplementation(async () => { - const result = await realGetAccount(); - accountReads += 1; - if (accountReads >= 2) { - signalSecondPreflight(); - } - return result; + 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', }); - const gateEntered = venue.armCreateGate(); - const replacePromise = provider.updatePositionTPSL({ + 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', }); - 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); + 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 0 + // (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('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({ + 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', - 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(result.success).toBe(false); + expect(result.error).toContain('too many'); expect( - calls.filter((call) => - [ - '_signCreateOrder', - '_signCreateGroupedOrders', - '_signCancelOrder', - ].includes(call.function), + 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('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({ + 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: '80000', - stopLossSize: '0.0005', + stopLossPrice: '85000', }); - expect(stopLoss.success).toBe(false); - expect(stopLoss.error).toContain('partial'); - expect(calls).toHaveLength(0); + 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('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 = { + 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' 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', + orderType: 'limit', + price: '90000', }); - 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(); - } + 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-15 authoritative settlement identity and recovery', () => { + describe('round-17 journal revisions, durable nonce ledger and independent restores', () => { /** - * 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. + * 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. @@ -4066,340 +20431,454 @@ describe('LighterProvider', () => { }); } } - (built.bridge.execute as jest.Mock).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('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); + 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 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({ + const crashed = await first.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, + 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; }); - const result = await provider.updatePositionTPSL({ + (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: '85000', + stopLossPrice: '86000', }); - 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]); + 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 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', + 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, }); - 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({ + const venueA = setupTriggerVenue(first.clientInstance, first.bridge); + venueA.seedTrigger('stop-loss', '80000'); + venueA.failResponseOnce(15); + const crashed = await first.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(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, }); - expect(first.success).toBe(false); - // Inside the validity window: blocked even though the venue answers - // not-found. - const immediate = await provider.updatePositionTPSL({ + 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', - 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'); + 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 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'); + 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 first = await provider.updatePositionTPSL({ + const failed = await built.provider.placeOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - 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(); + 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; } - }); - - 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({ + 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', - takeProfitPrice: '110000', - stopLossPrice: '75000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90500', }); - 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); + 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 replacement that terminal-fails after the old protection was cancelled parks DURABLE manual recovery', async () => { + 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'); - // 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(); - }); + delete (venue.rawTriggers[0] as { triggerPrice?: string }).triggerPrice; 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(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); - 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(); + 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'); - venue.setCreateTerminal('filled-partial'); + // 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', }); - // 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(result.error).toContain('faithfully restored'); expect( - calls.filter((call) => call.function === '_signCancelOrder'), + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, + ), ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); }); - 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'); + 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', + }, + ], + }, + ], + }); } - disk.delete(key); + return result; }, ); - const built = buildProvider({ platformDependencies: infra }); - const venue = setupTriggerVenue(built.clientInstance, built.bridge); - venue.seedTrigger('stop-loss', '80000'); - const first = await built.provider.updatePositionTPSL({ + const result = await 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({ + 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: '86000', + stopLossPrice: '84000', }); - expect(second.error).toBeUndefined(); - expect(second.success).toBe(true); - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + expect(renewed.error).toBeUndefined(); + expect(renewed.success).toBe(true); + expect(await provider.getPendingManualRecoveries()).toHaveLength(0); }); - it('startup recovery completes an interrupted replacement WITHOUT a new mutation call', async () => { + 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( @@ -4415,343 +20894,457 @@ describe('LighterProvider', () => { disk.delete(key); }, ); - const first = buildProvider({ platformDependencies: infra }); + 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'); - // 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); - }, - ); + venueA.failResponseOnce(15); 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 consumedNonce = lastSignedNonce(first.calls, '_signCancelOrder'); + killProvider(first); + const second = buildProvider({ + platformDependencies: infra, + registeredKey, + }); 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); + 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('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', + 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, }); - 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, + 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, + }, + ], + }), ); - recentVenue.setCreateTerminal('filled'); - const recentResult = await recent.provider.updatePositionTPSL({ + const blocked = await built.provider.placeOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - 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({ + // 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', - stopLossPrice: '85000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - expect(deepFirst.success).toBe(false); - // Bury THAT terminal row beyond the old ten-page traversal limit. - for (let filler = 0; filler < 1120; 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({ + 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', - stopLossPrice: '86000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - 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).toBeGreaterThan(10); - expect(inactiveCalls).toBeLessThanOrEqual(15); - expect(deepVenue.rawTriggers).toHaveLength(1); - expect(deepVenue.rawTriggers[0].triggerPrice).toBe('86000'); + expect(placed.success).toBe(true); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( + frozenNonce + 1, + ); }); - it('fails closed when inactive-history pagination repeats a cursor', async () => { - const built = buildProvider(); + 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); - venue.setCreateTerminal('filled'); - venue.failResponseOnce(14); - expect( - ( - await built.provider.updatePositionTPSL({ - symbol: 'BTC', - stopLossPrice: '85000', - }) - ).success, - ).toBe(false); - venue.setCreateTerminal('none'); - built.clientInstance.getInactiveOrders.mockResolvedValue({ + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ code: 200, - orders: [ - { - ...venue.rawInactive[0], - orderIndex: 999999, - clientOrderIndex: 999999, - }, - ], - nextCursor: 'repeated', + 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: 2, }); - - const retry = await built.provider.updatePositionTPSL({ + built.clientInstance.getTx.mockImplementation(async (hash: string) => + hash === 'dddd000000000001' + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 9, + nonce: frozenNonce, + status: 2, + } + : 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', - stopLossPrice: '86000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - expect(retry.success).toBe(false); - expect(retry.error).toContain('repeated cursor'); - expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(2); + expect(blocked.success).toBe(false); + expect(blocked.error).toContain('unresolved'); }); - it('enforces the inactive-history bound from actual returned rows', async () => { - const built = buildProvider(); + 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); - venue.setCreateTerminal('filled'); - venue.failResponseOnce(14); + 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( - ( - await built.provider.updatePositionTPSL({ - symbol: 'BTC', - stopLossPrice: '85000', - }) - ).success, - ).toBe(false); - venue.setCreateTerminal('none'); - const fillerOrder = { - ...venue.rawInactive[0], - status: 'canceled', - }; - const rows = (count: number, offset: number): RawTriggerOrder[] => - Array.from({ length: count }, (_entry, index) => ({ - ...fillerOrder, - orderIndex: offset + index, - clientOrderIndex: offset + index, - })); - built.clientInstance.getInactiveOrders - .mockResolvedValueOnce({ - code: 200, - orders: rows(6000, 100000), - nextCursor: 'second', - }) - .mockResolvedValueOnce({ - code: 200, - orders: rows(4001, 200000), - nextCursor: 'third', - }); - - const retry = await built.provider.updatePositionTPSL({ + 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', - stopLossPrice: '86000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90500', }); - expect(retry.success).toBe(false); - expect(retry.error).toContain('10001 rows'); - expect(built.clientInstance.getInactiveOrders).toHaveBeenCalledTimes(2); + expect(placed.success).toBe(true); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( + frozenNonce, + ); }); - it('fails closed when inactive history advances without returning rows', async () => { - const built = buildProvider(); + 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); - venue.setCreateTerminal('filled'); - venue.failResponseOnce(14); - expect( - ( - await built.provider.updatePositionTPSL({ - symbol: 'BTC', - stopLossPrice: '85000', - }) - ).success, - ).toBe(false); - venue.setCreateTerminal('none'); - built.clientInstance.getInactiveOrders.mockResolvedValue({ + const frozenNonce = venue.getVenueNonce(); + built.clientInstance.getNextNonce.mockImplementation(async () => ({ code: 200, - orders: [], - nextCursor: 'advanced', + 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', }); - - const retry = await built.provider.updatePositionTPSL({ + 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: '86000', + stopLossPrice: '87000', }); - expect(retry.success).toBe(false); - expect(retry.error).toContain('advanced without returning rows'); - }); - - 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', + 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]); }); - // 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'); - } + expect( + signedNonces.filter((nonce) => nonce === consumedNonce), + ).toHaveLength(1); + expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBeGreaterThan( + consumedNonce, + ); }); - 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 }); + 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 result = await built.provider.updatePositionTPSL({ + 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', - 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(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)); } - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + 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('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'); + 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; @@ -4765,368 +21358,348 @@ describe('LighterProvider', () => { ); const crashed = await first.provider.updatePositionTPSL({ symbol: 'BTC', - takeProfitPrice: '110000', - stopLossPrice: '75000', + stopLossPrice: '85000', }); 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)); + (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, + ); } - 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, + (second.bridge.execute as jest.Mock).mockImplementation( + (first.bridge.execute as jest.Mock).getMockImplementation() as never, ); - (infra.diskCache.setItem as jest.Mock).mockImplementation( - async (key: string, value: string) => { - disk.set(key, value); - }, + // 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, ); - (infra.diskCache.removeItem as jest.Mock).mockImplementation( + 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) => { - disk.delete(key); + const value = disk.get(key) ?? null; + if (key === journalPointerKey && staleGateArmed) { + staleGateArmed = false; + await staleGate; + } + return value; }, ); - 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({ + // 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: '85000', + stopLossPrice: '86000', }); - 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(); + 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) { - if ((await second.provider.getPendingManualRecoveries()).length === 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)); } - // 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, + 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 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', + 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', }); - 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, - ); + const journalSizes: number[] = []; (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'); + 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 + } } - return await realBridge(call); + disk.set(key, value); }, ); - 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) { + await built.provider.getOpenOrders(); if ( - [...disk.keys()].filter( - (key) => - key.startsWith('lighterTpslJournal:') && !key.includes('Index'), - ).length === 0 && - venueB.rawTriggers.length === 1 + venue.rawTriggers.length === 0 && + journalKeysOf(disk).length === 0 ) { 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', + // 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', ]); - 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(); + 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); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], }); - const crashed = await first.provider.updatePositionTPSL({ + venue.failResponseOnce(29); + const first = await built.provider.updateMargin({ symbol: 'BTC', - stopLossPrice: '85000', + 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 }], }); - 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; + 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)[])[1]); + } + if (call.function === '_signCreateOrder') { + const signer = Number((call.params as (string | number)[])[0]); + if (currentOwner !== signer) { + mismatches.push(`${String(currentOwner)}!=${String(signer)}`); + } } - 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, - ); + 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('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({ + 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(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(result.success).toBe(false); + expect(result.error).toContain('venue linkage'); expect( - [...disk.keys()].filter( - (key) => - key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + clientInstance.sendTx.mock.calls.filter( + ([txType]: [number]) => + txType === 14 || txType === 28 || txType === 15, ), ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); }); - it('a kick arriving while a recovery is in flight is preserved, not lost', async () => { + 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(); - let indexReads = 0; - let releaseIndexRead = (): void => undefined; - let gateArmed = true; - const indexGate = new Promise((resolve) => { - releaseIndexRead = resolve; - }); + // Inject the failure at clear-time RMW, after the journal payload + // and pointer are removed, rather than counting earlier safety reads. + const journalKey = `lighterTpslJournal:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + let failIndexReads = false; (infra.diskCache.getItem as jest.Mock).mockImplementation( async (key: string) => { - if (key.startsWith('lighterTpslJournalIndex:')) { - indexReads += 1; - if (gateArmed) { - gateArmed = false; - await indexGate; - } + if ( + key.startsWith('lighterTpslJournalIndex:') && + failIndexReads && + disk.has(key) && + !disk.has(journalKey) + ) { + throw new Error('index storage read failed'); } return disk.get(key) ?? null; }, @@ -5136,101 +21709,68 @@ describe('LighterProvider', () => { 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', - }, - ], - }), + (infra.diskCache.removeItem as jest.Mock).mockImplementation( + async (key: string) => { + disk.delete(key); + }, ); 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); + 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('a signing result without txHash or ExpiredAt refuses to submit', async () => { + it('an order failing AFTER a committed leverage change reports the partial venue state explicitly', 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 }; + 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 result; + return await realSend(txType, txInfo); }, ); - const result = await provider.updatePositionTPSL({ + const result = await provider.placeOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + leverage: 10, }); 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); + expect(result.error).toContain('PARTIAL STATE'); + expect(result.error).toContain('leverage'); + expect(result.error).toContain('10x'); }); }); - describe('round-16 durable intent, faithful restoration and authoritative resolution', () => { + describe('round-19 process-wide serialization, complete identity and real OCO semantics', () => { /** - * Durable disk map + infra wiring shared by restart scenarios. + * Durable disk map + infra wiring shared by scenarios. * * @returns The disk map and mocked infrastructure bound to it. */ @@ -5256,131 +21796,223 @@ describe('LighterProvider', () => { return { disk, infra }; }; - const journalKeysOf = (disk: Map): string[] => - [...disk.keys()].filter( - (key) => - key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + 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, ); + }; - /** - * 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('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('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 }); + 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('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 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); - 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); - } + // 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) { - await second.provider.getOpenOrders(); - if ( - venueB.rawTriggers.length === 0 && - journalKeysOf(disk).length === 0 - ) { + if ((await first.provider.getPendingManualRecoveries()).length === 1) { 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, + expect(venueA.events.filter((event) => event === 'create')).toHaveLength( + createsBeforeRecovery, ); + expect(await first.provider.getPendingManualRecoveries()).toHaveLength(1); + expect(venueA.rawTriggers).toHaveLength(0); }); - it('remove-only: an accepted cancel with lost response resolves by exact tx identity without restoring or double-cancelling', async () => { + it('concurrent persists for DIFFERENT symbols never lose an index entry (index RMW mutex)', 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({ + // 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', }); - 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, + 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; + }, ); - expect(venueB.events.filter((event) => event === 'create')).toHaveLength( - 0, + 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('a v2 journal without a durable operation intent fails closed as malformed', async () => { + 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( @@ -5390,27 +22022,25 @@ describe('LighterProvider', () => { disk.set( `lighterTpslJournal:testnet:${settlementKey}`, JSON.stringify({ - version: 3, + version: 2, recordedAt: 5, - operationId: 'op-intentless', + operationId: 'op-v2', createdAt: 5, - nextAttemptId: 2, - venueCheckpoint: 0, apiKeyIndex: 7, - phase: 'cancelling', - priorGrouping: 'independent', + intent: 'replace', + phase: 'creating', priorTriggers: [], positionFingerprint: null, attempts: [ { - kind: 'cancel', + kind: 'create', attemptId: 1, nonce: 999, - outcome: 'accepted', - orderId: '424242', - txHash: 'ffff00000002', + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', expiresAt: 9_999_999_999_999, - role: 'stale', + role: 'replacement', }, ], }), @@ -5418,6618 +22048,15044 @@ describe('LighterProvider', () => { 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.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); + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + expect(await built.provider.getPendingManualRecoveries()).toHaveLength(0); }); + }); - 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(); + 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' }], + }, + ], }); - const crashed = await first.provider.updatePositionTPSL({ + // 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(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({ + 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(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, + expect(second.success).toBe(true); + expect(mutationsAfterFirst('_signCreateOrder')).toBeGreaterThan( + createsBefore, ); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); }); - it('a live OCO leg failing after activation parks durable manual recovery (never a silent partial pair)', async () => { + 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('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 ( + // Fail the CANCEL submission once, after the create was accepted. + const venueSendTx = clientInstance.sendTx.getMockImplementation() as ( txType: number, txInfo: string, ) => Promise; - let raced = false; + let cancelFailed = 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' }); - } + if (txType === 15 && !cancelFailed) { + cancelFailed = true; + venue.stagedCancels.shift(); + throw new Error('cancel submission failed'); } - return result; + 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', - takeProfitPrice: '111000', - stopLossPrice: '81000', + stopLossPrice: '85000', }); expect(result.success).toBe(false); - expect(result.error).toContain('MANUAL re-establishment'); - expect(await provider.getPendingManualRecoveries()).toHaveLength(1); - const renewed = await provider.updatePositionTPSL({ + 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: '84000', + stopLossPrice: '86000', }); - expect(renewed.error).toBeUndefined(); - expect(renewed.success).toBe(true); - expect(await provider.getPendingManualRecoveries()).toHaveLength(0); + expect(retry.error).toBeUndefined(); + expect(retry.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); }); - it('a stale trigger whose wire intent cannot be faithfully restored refuses the update BEFORE any mutation', async () => { + 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('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'; + 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.success).toBe(false); - expect(result.error).toContain('faithfully restored'); + 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( - clientInstance.sendTx.mock.calls.filter( - ([txType]: [number]) => - txType === 14 || txType === 28 || txType === 15, - ), - ).toHaveLength(0); + 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('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({ + 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(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({ + 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: '85000', + stopLossPrice: '86000', }); - expect(update.success).toBe(false); - expect(update.error).toContain('unresolved'); + 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('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 }); + 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'); - // The sequencer consumes the nonce, records terminal status 0 - // (failed), mutates nothing, and the response is lost. - venueA.failExecutionOnceFor(15); - const crashed = await first.provider.updatePositionTPSL({ + // 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, }); - expect(crashed.success).toBe(false); - killProvider(first); - expect(venueA.rawTriggers).toHaveLength(1); - const second = buildProvider({ platformDependencies: infra }); 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); } - 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({ + // 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(result.success).toBe(false); - expect(result.error).toContain('too many'); + expect(blocked.success).toBe(false); expect( - built.clientInstance.sendTx.mock.calls.filter( - ([txType]: [number]) => - txType === 14 || txType === 28 || txType === 15, + second.calls.filter((call) => + ['_signCreateGroupedOrders'].includes(call.function), ), ).toHaveLength(0); - expect(venue.rawTriggers).toHaveLength(5); - expect(journalKeysOf(disk)).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('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)); + 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 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({ + // 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(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(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('85000'); + expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); }); - 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({ + 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(); }); - 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({ + const result = await provider.updatePositionTPSL({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + stopLossPrice: '85000', }); - 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, - ); + expect(result.success).toBe(false); + // NO automatic restore: the failure parks a durable MANUAL state. + expect(result.error).toContain('MANUAL re-establishment'); }); - }); - 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, + 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'), ); - (infra.diskCache.setItem as jest.Mock).mockImplementation( + 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) => { - disk.set(key, value); + v1Disk.set(key, value); }, ); - (infra.diskCache.removeItem as jest.Mock).mockImplementation( + (infraV1.diskCache.removeItem as jest.Mock).mockImplementation( async (key: string) => { - disk.delete(key); + v1Disk.delete(key); }, ); - return { disk, infra }; - }; - - const journalKeysOf = (disk: Map): string[] => - [...disk.keys()].filter( - (key) => - key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + const v1SettlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + v1Disk.set( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([v1SettlementKey]), ); - - /** - * 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); - }, + v1Disk.set( + `lighterTpslJournal:testnet:${v1SettlementKey}`, + JSON.stringify({ version: 1, recordedAt: 5, attempts: [] }), ); - const crashed = await first.provider.updatePositionTPSL({ + 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', - stopLossPrice: '85000', + takeProfitPrice: '110000', }); - 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; + 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', }); - (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; - }, + 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'), ); - // 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({ + const writeFailBuilt = buildProvider({ + platformDependencies: infraWriteFail, + }); + const writeFailVenue = setupTriggerVenue( + writeFailBuilt.clientInstance, + writeFailBuilt.bridge, + ); + const writeFailResult = await writeFailBuilt.provider.updatePositionTPSL({ symbol: 'BTC', - stopLossPrice: '86000', + takeProfitPrice: '110000', }); - 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); + 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 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({ + 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(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, + 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 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', + 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; }); - 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', + const removeGate = new Promise((resolve) => { + releaseRemove = resolve; }); - 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, + // 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); + }, ); - }); - - 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({ + 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(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(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'), ); - 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({ + const marginsResult = await margins.provider.validateOrder({ symbol: 'BTC', isBuy: true, size: '0.001', orderType: 'limit', - price: '90500', + price: '90000', + leverage: 10, }); - 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, + 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'), ); - }); - - 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({ + const priceResult = await price.provider.validateOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: true, + size: '0.001', + orderType: 'market', }); - 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); + expect(priceResult.isValid).toBe(false); + const priceClose = await price.provider.validateClosePosition({ + symbol: 'BTC', + }); + expect(priceClose.isValid).toBe(false); }); - it('a prior whose wire values cannot be integerized refuses the update before any mutation (writer/loader symmetry)', async () => { - const { provider, clientInstance, bridge } = buildProvider(); + 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'); - // 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); + // 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('the LIVE transition never re-attaches protection after a post-cancel failure: durable manual recovery is parked', async () => { - const { provider, clientInstance, bridge } = buildProvider(); + 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); - 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 ( + 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 raced = false; + 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) => { - 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', - }, - ], - }, - ], - }); + 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 result; + return await venueSendTx(txType, txInfo); }, ); - 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, + 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.each([ + ['increases', { position: '0.2', sign: 1 }], + ['flips side', { position: '0.1', sign: -1 }], + ])( + 'aborts with zero create/cancel signatures when the position %s before TP/SL signing', + async (_label, livePosition) => { + const { provider, calls, clientInstance, bridge } = buildProvider(); + const venue = setupTriggerVenue(clientInstance, bridge); + venue.seedTrigger('stop-loss', '80000'); + let accountReads = 0; + clientInstance.getAccountByIndex.mockImplementation(async () => { + accountReads += 1; + return { + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + ...(accountReads >= 3 ? livePosition : {}), + }, + ], + }, + ], + }; + }); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('position changed before TP/SL signing'); + expect( + calls.filter( + (call) => + call.function === '_signCreateOrder' || + call.function === '_signCreateGroupedOrders' || + call.function === '_signCancelOrder', + ), + ).toHaveLength(0); + expect(venue.rawTriggers).toHaveLength(1); + expect(venue.rawTriggers[0].triggerPrice).toBe('80000'); + }, + ); + + it('fails closed without writing an undiscoverable live manual recovery when the index already has 64 keys', async () => { + const infra = createMockInfrastructure(); + const fullIndex = Array.from( + { length: 64 }, + (_, index) => `0xother:${String(index)}:7:ETH`, + ); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify(fullIndex), ); - expect(await provider.getPendingManualRecoveries()).toHaveLength(1); - const renewed = await provider.updatePositionTPSL({ - symbol: 'BTC', - stopLossPrice: '84000', + const built = buildProvider({ platformDependencies: infra }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + let readsSeen = 0; + const realActive = + built.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; + built.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(); }); - 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({ + const result = await built.provider.updatePositionTPSL({ symbol: 'BTC', + stopLossPrice: '85000', }); - 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); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + expect(result.success).toBe(false); + expect(result.error).toContain('manual-recovery index is full'); + expect( + JSON.parse( + (await infra.diskCache.getItem( + 'lighterTpslManualIndex:testnet', + )) as string, + ), + ).toHaveLength(64); + expect( + await infra.diskCache.getItem( + `lighterTpslManual:testnet:${settlementKey}`, + ), + ).toBeNull(); + expect( + JSON.parse( + (await infra.diskCache.getItem( + 'lighterTpslJournalIndex:testnet', + )) as string, + ), + ).toContain(settlementKey); }); - }); - 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(); + it('retains a startup journal when the 65th manual recovery cannot be indexed', async () => { 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); - }, + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify( + Array.from( + { length: 64 }, + (_, index) => `0xother:${String(index)}:7:ETH`, + ), + ), ); - (infra.diskCache.removeItem as jest.Mock).mockImplementation( - async (key: string) => { - disk.delete(key); - }, + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), ); - return { disk, infra }; - }; - - const journalKeysOf = (disk: Map): string[] => - [...disk.keys()].filter( - (key) => - key.startsWith('lighterTpslJournal:') && !key.includes('Index'), + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 2, + recordedAt: 5, + operationId: 'op-v2-full-index', + createdAt: 5, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorTriggers: [], + attempts: [], + }), ); + const built = buildProvider({ platformDependencies: infra }); + setupTriggerVenue(built.clientInstance, built.bridge); - /** - * 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 }); + await built.provider.getOpenOrders(); + for (let attempt = 0; attempt < 40; attempt += 1) { + const recoveryFailure = ( + infra.debugLogger.log as jest.Mock + ).mock.calls.some( + ([message, context]: [string, { error?: string }]) => + message.includes('journal entry recovery failed') && + context.error?.includes('manual-recovery index is full'), + ); + if (recoveryFailure) { + break; } + await new Promise((resolve) => setTimeout(resolve, 10)); } - 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]); - }; + expect(infra.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('journal entry recovery failed'), + expect.objectContaining({ + settlementKey, + error: expect.stringContaining( + 'manual-recovery index is full', + ) as string, + }), + ); + expect( + await infra.diskCache.getItem( + `lighterTpslManual:testnet:${settlementKey}`, + ), + ).toBeNull(); + expect( + JSON.parse( + (await infra.diskCache.getItem( + 'lighterTpslJournalIndex:testnet', + )) as string, + ), + ).toContain(settlementKey); + expect( + await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${settlementKey}`, + ), + ).not.toBeNull(); + }); - 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, + 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 venueA = setupTriggerVenue(first.clientInstance, first.bridge); - venueA.seedTrigger('stop-loss', '80000'); - venueA.failResponseOnce(15); - const crashed = await first.provider.updatePositionTPSL({ - symbol: 'BTC', + const details = { + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], + }; + clientInstance.getOrderBookDetails.mockImplementation(async () => { + await priceGate; + return details; }); - expect(crashed.success).toBe(false); - const consumedNonce = lastSignedNonce(first.calls, '_signCancelOrder'); - killProvider(first); - const second = buildProvider({ - platformDependencies: infra, - registeredKey, + const placement = provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', }); - 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 () => ({ + 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, - 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', + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], }); - expect(resolvedRestart.success).toBe(true); - expect(await second.provider.getRecoveredDispatches()).toStrictEqual([]); - const placed = await second.provider.placeOrder({ + const request = { symbol: 'BTC', isBuy: true, size: '0.001', - orderType: 'limit', + 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(placed.error).toBeUndefined(); - expect(placed.success).toBe(true); - expect(lastSignedNonce(second.calls, '_signCreateOrder')).toBe( - consumedNonce + 1, + 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('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 () => ({ + it('keeps the old protection untouched when creating the replacement fails', async () => { + const { provider, calls, bridge, clientInstance } = buildProvider(); + clientInstance.getActiveOrders.mockResolvedValue({ 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', + 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', + }, + ], }); - // 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({ + 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', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + stopLossPrice: '85000', }); - 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({ + 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', - 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: 2, + size: '1e300', + currentPrice: 100000, }); - built.clientInstance.getTx.mockImplementation(async (hash: string) => - hash === 'dddd000000000001' - ? { - code: 200, - hash, - accountIndex: 28, - apiKeyIndex: 9, - nonce: frozenNonce, - status: 2, - } - : 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({ + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('integer range'); + const closeExecution = await provider.closePosition({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + size: '1e300', + currentPrice: 100000, }); - expect(blocked.success).toBe(false); - expect(blocked.error).toContain('unresolved'); + expect(closeExecution.success).toBe(false); + expect(closeExecution.error).toContain('integer range'); + expect(calls).toHaveLength(0); }); - 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 () => ({ + 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, - 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', + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: reference, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], }); - 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({ + const buyRequest = { symbol: 'BTC', isBuy: true, size: '0.001', - orderType: 'limit', - price: '90500', - }); - expect(placed.success).toBe(true); - expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( - frozenNonce, - ); + 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('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 () => ({ + 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, - 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', + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: reference, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], }); - 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', + // 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 }, + ], + }, + ], }); - expect(resolvedNext.success).toBe(true); - expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); - const placed = await built.provider.placeOrder({ + const shortCloseValidation = await provider.validateClosePosition({ 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({ + expect(shortCloseValidation.isValid).toBe(false); + expect(shortCloseValidation.error).toContain('uint32'); + const shortCloseExecution = await provider.closePosition({ 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({ + 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', - 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({ + expect(longCloseValidation.isValid).toBe(true); + const longCloseExecution = await provider.closePosition({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '91000', }); - expect(placedAfter.success).toBe(true); - expect(lastSignedNonce(built.calls, '_signCreateOrder')).toBe( - frozenNonce + 1, - ); + expect(longCloseExecution.success).toBe(true); }); - 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, + 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); + }); + + describe('isolated margin settlement', () => { + describe('bounded exact margin execution polling', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + function setup(): BuiltProvider & { + exact: (hash: string, status: number) => Record; + } { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], + }); + const exact = ( + hash: string, + status: number, + ): Record => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: built.calls + .find((call) => call.function === '_signUpdateMargin') + ?.params.at(-1), + status, + }); + return { ...built, exact }; + } + + it('waits for pending then executed without signing or sending twice', async () => { + const built = setup(); + built.clientInstance.getTx + .mockImplementationOnce(async (hash: string) => + built.exact(hash, 1), + ) + .mockImplementation(async (hash: string) => built.exact(hash, 2)); + const pending = built.provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }); + await jest.advanceTimersByTimeAsync(3000); + expect(await pending).toStrictEqual({ success: true }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect( + built.calls.filter((call) => call.function === '_signUpdateMargin'), + ).toHaveLength(1); + }); + + it('stops on an exact failed outcome after pending without resending', async () => { + const built = setup(); + built.clientInstance.getTx + .mockImplementationOnce(async (hash: string) => + built.exact(hash, 1), + ) + .mockImplementation(async (hash: string) => built.exact(hash, 0)); + const pending = built.provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }); + await jest.advanceTimersByTimeAsync(3000); + expect(await pending).toMatchObject({ + success: false, + error: expect.stringContaining( + 'margin transaction failed', + ) as string, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(2); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }); + + it.each(['pending', 'missing', 'foreign'])( + 'retains %s uncertainty after bounded reads without resending', + async (mode) => { + const built = setup(); + built.clientInstance.getTx.mockImplementation( + async (hash: string) => + mode === 'missing' + ? null + : { + ...built.exact(hash, mode === 'pending' ? 1 : 2), + ...(mode === 'foreign' ? { apiKeyIndex: 8 } : {}), + }, + ); + const pending = built.provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }); + await jest.advanceTimersByTimeAsync(3000); + expect(await pending).toMatchObject({ + success: false, + error: expect.stringContaining('remains unresolved') as string, + }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(10); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + const recovered = await built.provider.getRecoveredDispatches(); + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ outcome: 'unknown' }); + }, + ); + + it('stops reads when the account changes while waiting', async () => { + const built = setup(); + built.clientInstance.getTx.mockImplementation( + async (hash: string) => { + built.getUserAddressMock.mockReturnValue('0xdef'); + return built.exact(hash, 1); + }, + ); + const pending = built.provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }); + await jest.advanceTimersByTimeAsync(3000); + expect(await pending).toMatchObject({ success: false }); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(1); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + }); }); - 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); + + it.each([ + ['foreign-account', { index: 99 }, {}], + ['foreign-wallet', { l1Address: '0xforeign' }, {}], + ['foreign-market', {}, { marketId: 99 }], + ['invalid-sign', {}, { sign: 0 }], + ['flat', {}, { position: '0' }], + ['missing-allocation', {}, { allocatedMargin: undefined }], + ['negative-allocation', {}, { allocatedMargin: '-1' }], + ])( + 'refuses %s removal state before signing', + async (_name, accountChange, positionChange) => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + ...accountChange, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + ...positionChange, + }, + ], + }, + ], + }); + expect( + (await provider.updateMargin({ symbol: 'BTC', amount: '-5' })) + .success, + ).toBe(false); + expect(calls).toHaveLength(0); }, ); - 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, + + it('refuses fractional micro-USDC rather than signing rounded collateral', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], + }); + expect( + (await provider.updateMargin({ symbol: 'BTC', amount: '5.0000009' })) + .success, + ).toBe(false); + expect(calls).toHaveLength(0); }); - 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; + + it('reports exact failed margin execution without claiming success or pending acceptance', async () => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], + }); + clientInstance.getTx.mockImplementation(async (hash: string) => { + const call = calls.find( + (item) => item.function === '_signUpdateMargin', + ); + return { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: call?.params.at(-1), + status: 0, + }; + }); + expect( + await provider.updateMargin({ symbol: 'BTC', amount: '5' }), + ).toMatchObject({ + success: false, + error: + 'Lighter margin transaction failed; refresh its exact outcome before retrying', + }); }); - 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; + + it.each(['5', '-5'])( + 'confirms exact transaction execution before reporting margin %s success', + async (amount) => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], + }); + clientInstance.getTx.mockImplementation(async (hash: string) => { + const call = calls.find( + (item) => item.function === '_signUpdateMargin', + ); + return { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: call?.params.at(-1), + status: 2, + }; + }); + expect( + await provider.updateMargin({ symbol: 'BTC', amount }), + ).toStrictEqual({ success: true }); + expect(clientInstance.getTx).toHaveBeenCalledTimes(1); + expect( + calls.find((item) => item.function === '_signUpdateMargin') + ?.params[3], + ).toBe(amount === '5' ? 1 : 0); }, ); - // 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', + + it('keeps accepted margin blocked after expiry and nonce advance across provider restart', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + const account = { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], marginMode: 1, allocatedMargin: '2000' }, + ], + }; + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [account], + }); + const first = await built.provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }); + expect(first.success).toBe(false); + const clock = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 1_000_000); + try { + const restarted = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + restarted.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [account], + }); + restarted.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: 1000, + }); + restarted.clientInstance.getTx.mockResolvedValue(null); + expect( + ( + await restarted.provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }) + ).success, + ).toBe(false); + expect( + restarted.calls.filter( + (call) => call.function === '_signUpdateMargin', + ), + ).toHaveLength(0); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + clock.mockRestore(); + } }); - 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; + + it('rechecks allocated margin after signing and clears only the unsent attempt', async () => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const account = { + ...ACCOUNT, + positions: [ + { ...ACCOUNT.positions[0], marginMode: 1, allocatedMargin: '2000' }, + ], + }; + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [account], + }); + const execute = jest.spyOn(built.bridge, 'execute'); + const sign = execute.getMockImplementation(); + if (!sign) { + throw new Error('Missing signer fixture'); } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - expect(venue.rawTriggers.map((row) => row.triggerPrice)).toStrictEqual([ - '86000', - ]); - expect(journalKeysOf(disk)).toHaveLength(0); - }, 15_000); + execute.mockImplementation(async (call: LighterWasmCall) => { + const result = await sign(call); + if (call.function === '_signUpdateMargin') { + account.positions[0].allocatedMargin = '2'; + } + return result; + }); + const result = await built.provider.updateMargin({ + symbol: 'BTC', + amount: '-5', + }); + expect(result.success).toBe(false); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); + }); - 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: [ + it('rejects an addition beyond authoritative available balance without signing', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ { - orderId: '9000', - side: 'sell', - wireOrderType: 2, - wireTimeInForce: 0, - orderExpiry: 0, - price: '80000', - triggerPrice: '80000', - remainingSize: '0.001', + ...ACCOUNT, + availableBalance: '2', + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], }, ], - // 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', + }); + expect( + (await provider.updateMargin({ symbol: 'BTC', amount: '5' })).success, + ).toBe(false); + expect(calls).toHaveLength(0); }); - 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('rejects cross-position margin adjustment before signing', async () => { + const { provider, calls } = buildProvider(); + expect( + await provider.updateMargin({ symbol: 'BTC', amount: '5' }), + ).toMatchObject({ + success: false, + error: 'Lighter margin adjustment requires an open isolated position', + }); + expect(calls).toHaveLength(0); + }); + + it('keeps accepted additive margin unresolved across another request', async () => { + const { provider, clientInstance, calls } = buildProvider({ + registeredKey: '9c'.repeat(40), + }); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], + }); + const first = await provider.updateMargin({ + symbol: 'BTC', + amount: '5', + }); + expect(first.success).toBe(false); + expect(first.error).toContain('pending'); + expect( + (await provider.updateMargin({ symbol: 'BTC', amount: '5' })).success, + ).toBe(false); + expect( + calls.filter((call) => call.function === '_signUpdateMargin'), + ).toHaveLength(1); + }); - 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', + it('refuses removal larger than authoritative allocated margin', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2', + }, + ], + }, + ], + }); + expect( + (await provider.updateMargin({ symbol: 'BTC', amount: '-5' })) + .success, + ).toBe(false); + expect(calls).toHaveLength(0); }); - expect(first.success).toBe(false); - const retry = await built.provider.updateMargin({ + }); + + 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: '10', + amount: '1e300', }); - expect(retry.success).toBe(false); - expect(retry.error).toContain('actually completed'); - await acknowledgeAllRecovered(built.provider); - const after = await built.provider.updateMargin({ + expect(overflow.success).toBe(false); + expect(overflow.error).toContain('integer range'); + const infinite = await provider.updateMargin({ symbol: 'BTC', - amount: '10', + amount: 'Infinity', }); - expect(after.success).toBe(true); + expect(infinite.success).toBe(false); + expect(calls).toHaveLength(0); }); - 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 = { + 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', + orderType: 'limit' as const, 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)[])[1]); - } - 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); + 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('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', + 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, + }, + ], }); - 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); + 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('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({ + 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', - stopLossPrice: '86000', - }); - expect(retry.error).toBeUndefined(); - expect(retry.success).toBe(true); + 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('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({ + + 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', + orderType: 'limit' as const, price: '90000', - leverage: 10, + 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.each([ + ['limit', undefined], + ['market', undefined], + ['limit', 'IOC'], + ] as const)( + 'rejects an explicit off-grid %s order before signer setup (timeInForce %s)', + async (orderType, timeInForce) => { + const { provider, calls } = buildProvider(); + const request = { + symbol: 'BTC', + isBuy: true, + size: '0.000201', + orderType, + ...(orderType === 'limit' ? { price: '100000' } : {}), + ...(timeInForce ? { timeInForce } : {}), + }; + const validation = await provider.validateOrder(request); + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('size grid'); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(false); + expect(placement.error).toContain('size grid'); + expect(calls).toHaveLength(0); + }, + ); + + it('does not apply maker-only minimums to market orders', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 40000 }], }); - expect(result.success).toBe(false); - expect(result.error).toContain('PARTIAL STATE'); - expect(result.error).toContain('leverage'); - expect(result.error).toContain('10x'); + 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(true); + const placement = await provider.placeOrder(request); + expect(placement.success).toBe(true); + expect( + calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + }); + + 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('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 }; - }; + 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); - 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, - ); + // 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(); } - (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({ + 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', }), - second.provider.placeOrder({ + provider.placeOrder({ symbol: 'BTC', isBuy: true, size: '0.001', orderType: 'limit', - price: '90500', + price: '90001', + }), + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90002', }), ]); - expect(resultA.success).toBe(true); - expect(resultB.success).toBe(true); - const signedNonces = [...first.calls, ...second.calls] + for (const result of results) { + expect(result.success).toBe(true); + } + const ids = 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]); + .map((call) => call.params[2]); + 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('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, + 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); + }, + ); + 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]); + 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]); + 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', }); - 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(); + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '110000', + stopLossPrice: '80000', }); - const crashed = await first.provider.updatePositionTPSL({ + 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('rejects an off-grid partial dust close instead of rounding it up', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue( + dustPosition('0.0001'), + ); + const result = await provider.placeOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: false, + size: '0.000099', + orderType: 'market', + reduceOnly: true, + currentPrice: 90000, }); - 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, + expect(result.success).toBe(false); + expect(result.error).toContain('size grid'); + expect(calls).toHaveLength(0); + }); + + it('an exact-size dust market close is not bumped to the maker minimum', async () => { + const { provider, calls, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue( + dustPosition('0.0001'), ); - // 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, + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: false, + size: '0.0001', + orderType: 'market', + reduceOnly: true, + currentPrice: 90000, }); - 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(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', ); - expect(await first.provider.getPendingManualRecoveries()).toHaveLength(1); - expect(venueA.rawTriggers).toHaveLength(0); + expect(orderCall?.params[3]).toBe('10'); + }); + + it('validateOrder does not apply maker minimums to market opens', async () => { + const { provider } = buildProvider(); + const result = await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.00001', + orderType: 'market', + isFullClose: true, + currentPrice: 90000, + }); + expect(result).toStrictEqual({ isValid: true }); }); - 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; - }, + it('validateOrder approves a live-verified reduce-only full close like placement', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue( + dustPosition('0.00001'), ); - 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({ + const result = await provider.validateOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: false, + size: '0.00001', + orderType: 'market', + reduceOnly: true, + currentPrice: 90000, }); - 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); + expect(result).toStrictEqual({ isValid: 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'); + it('validateClosePosition rejects the shapes closePosition refuses', async () => { + const { provider } = buildProvider(); expect( - clientInstance.sendTx.mock.calls.filter( - ([txType]: [number]) => txType === 15, - ), - ).toHaveLength(0); - expect(venue.rawTriggers).toHaveLength(1); + 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('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, + 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); + // Maker minimums do not apply to a market partial close, but explicit + // sizes must still land exactly on the base-size grid. + const partialValidation = await provider.validateClosePosition({ + symbol: 'BTC', + size: '0.000099', + currentPrice: 90000, }); - setupTriggerVenue(built.clientInstance, built.bridge); - const placed = await built.provider.placeOrder({ + expect(partialValidation.isValid).toBe(false); + expect(partialValidation.error).toContain('size grid'); + const partialExecution = await provider.closePosition({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + size: '0.000099', + currentPrice: 90000, }); - expect(placed.error).toBeUndefined(); - expect(placed.success).toBe(true); + expect(partialExecution.success).toBe(false); + expect(partialExecution.error).toContain('size grid'); + // 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('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({ + 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('uses the fresh venue price for a market close without applying maker minimums', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + }, + ], + }); + const result = await provider.validateClosePosition({ symbol: 'BTC', - takeProfitPrice: '110000', + size: '0.00005', + currentPrice: 1_000_000, }); - expect(result.error).toBeUndefined(); - expect(result.success).toBe(true); - expect(await built.provider.getPendingManualRecoveries()).toHaveLength(0); + expect(result.isValid).toBe(true); + const execution = await provider.closePosition({ + symbol: 'BTC', + size: '0.00005', + currentPrice: 1_000_000, + }); + expect(execution.success).toBe(true); }); - }); - 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 }, + it('fails market close validation closed when the fresh venue price is missing or zero', async () => { + for (const orderBookDetails of [ + [ + { + ...BTC_MARKET, + lastTradePrice: undefined, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], + [ + { + ...BTC_MARKET, + lastTradePrice: 0, + minInitialMarginFraction: 200, + maintenanceMarginFraction: 120, + }, + ], ]) { + const { provider, clientInstance } = buildProvider(); clientInstance.getAccountByIndex.mockResolvedValue({ code: 200, accounts: [ { ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], ...overrides }], + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], }, ], }); - const tpsl = await provider.updatePositionTPSL({ + clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails, + }); + const result = await provider.validateClosePosition({ symbol: 'BTC', - takeProfitPrice: '110000', + size: '0.0005', + currentPrice: 100000, }); - expect(tpsl.success).toBe(false); - expect(tpsl.error).toContain('Invalid Lighter venue data'); - const close = await provider.closePosition({ + 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(close.success).toBe(false); - expect(close.error).toContain('Invalid Lighter venue data'); + expect(execution.success).toBe(false); + expect(execution.error).toContain('No live venue price available'); } - 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(); + 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.1oops' }], + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], }, ], }); - // 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({ + // 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', - 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(); - } + 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('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({ + 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', - stopLossPrice: '85000', + orderType: 'limit', + price: 'Infinity', }); - expect(first.error).toBeUndefined(); - expect(first.success).toBe(true); - const second = await provider.updatePositionTPSL({ + expect(closeValidation.isValid).toBe(false); + expect(closeValidation.error).toContain('Invalid limit price'); + const orderValidation = await provider.validateOrder({ symbol: 'BTC', - stopLossPrice: '86000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: 'Infinity', }); - 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({ + expect(orderValidation.isValid).toBe(false); + expect(orderValidation.error).toContain('Invalid limit price'); + const placement = await provider.placeOrder({ symbol: 'BTC', - stopLossPrice: '85000', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: 'Infinity', }); - 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({ + 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', - stopLossPrice: '86000', + 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, }); - expect(second.success).toBe(true); - expect(mutationsAfterFirst('_signCreateOrder')).toBeGreaterThan( - createsBefore, - ); - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + 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('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({ + 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', - stopLossPrice: '85000', + levels: 5, + callback: bookCallback, }); - 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', + 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: { + nonce: 10, + bids: [ + { price: '90000', size: '0.5' }, + { price: '89990', size: '1.5' }, + ], + asks: [ + { price: '90010', size: '0.4' }, + { price: '90020', size: '2.0' }, + ], + }, + }), }); - 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(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', + ] as const) { + expect(Number.isFinite(parseFloat(level[field]))).toBe(true); + } + } } - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + unsubscribe(); }); - 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({ + it('rejects a malformed order book frame without emitting or poisoning its cached nonce', async () => { + const { provider } = buildProvider({ webSocketCtor: fakeStreamCtor }); + const bookCallback = jest.fn(); + const unsubscribe = provider.subscribeToOrderBook({ symbol: 'BTC', - stopLossPrice: '85000', + callback: bookCallback, }); - 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', + 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: { + nonce: 10, + bids: [{ price: '90000', size: '0.5' }], + asks: [{ price: '90010', size: '0.4' }], + }, + }), }); - expect(retry.error).toBeUndefined(); - expect(retry.success).toBe(true); - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + bookCallback.mockClear(); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'update/order_book', + channel: 'order_book:1', + order_book: { + begin_nonce: 10, + nonce: 11, + bids: [{ price: '89990', size: '12.5oops' }], + asks: [], + }, + }), + }); + expect(bookCallback).not.toHaveBeenCalled(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'update/order_book', + channel: 'order_book:1', + order_book: { + begin_nonce: 10, + nonce: 12, + bids: [{ price: '89990', size: '1.5' }], + asks: [], + }, + }), + }); + expect(bookCallback).toHaveBeenCalledTimes(1); + const book = bookCallback.mock.calls[0][0]; + expect(book.bids).toStrictEqual([ + expect.objectContaining({ price: '90000', size: '0.5' }), + expect.objectContaining({ price: '89990', size: '1.5' }), + ]); + unsubscribe(); }); - 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({ + it('discards an order book after a nonce gap and requests a fresh snapshot', async () => { + const { provider } = buildProvider({ webSocketCtor: fakeStreamCtor }); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderBook({ symbol: 'BTC', - stopLossPrice: '85000', + callback, }); - expect(result.error).toBeUndefined(); - expect(result.success).toBe(true); - // Stale reduce-only leftovers still cleaned up. - expect(venue.rawTriggers).toHaveLength(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: { + nonce: 10, + bids: [{ price: '90000', size: '1' }], + asks: [{ price: '90010', size: '1' }], + }, + }), + }); + expect(callback).toHaveBeenCalledTimes(1); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'update/order_book', + channel: 'order_book:1', + order_book: { + begin_nonce: 12, + nonce: 13, + bids: [{ price: '90000', size: '0' }], + asks: [{ price: '90020', size: '2' }], + }, + }), + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(socket.sent).toContainEqual( + JSON.stringify({ type: 'unsubscribe', channel: 'order_book/1' }), + ); + expect(socket.sent).toContainEqual( + JSON.stringify({ type: 'subscribe', channel: 'order_book/1' }), + ); + unsubscribe(); }); - 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', + 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, }); - 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({ + 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 + { + t: 1600, + o: null as unknown as number, + h: 2, + l: 0.5, + c: 1.5, + v: 10, + }, + { t: 1700, o: '', h: 2, l: 0.5, c: 1.5, v: 10 }, + { + t: 1800, + o: false as unknown as number, + h: 2, + l: 0.5, + c: 1.5, + v: 10, + }, + ], + }); + const candleCallback = jest.fn(); + const unsubscribe = provider.subscribeToCandles({ symbol: 'BTC', - stopLossPrice: '86000', + interval: '1h', + callback: candleCallback, }); - expect(await provider.getRecoveredDispatches()).toStrictEqual([]); - expect(second.error).toBeUndefined(); - expect(second.success).toBe(true); + 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 }, + { t: 4000, o: null, h: 2, l: 1, c: 2, v: 5 }, + { t: 5000, o: '', h: 2, l: 1, c: 2, v: 5 }, + { t: 6000, o: false, h: 2, l: 1, c: 2, v: 5 }, + ], + }), + }); + const live = + candleCallback.mock.calls[candleCallback.mock.calls.length - 1][0]; 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'); + 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', + ] as const) { + expect(Number.isFinite(parseFloat(candle[field]))).toBe(true); + } + } + unsubscribe(); }); - 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', + it('withholds a fills snapshot containing unsupported (nonzero-fee) fills', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, }); - 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', + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], }); - 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); + 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('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, + it.each([ + ['non-finite size', { size: '1e999' }], + ['nonparticipant account', { ask_account_id: 7, bid_account_id: 8 }], + ['wrong maker role type', { is_maker_ask: 1 }], + ['null fee', { taker_fee: null }], + ['null counterparty fee', { maker_fee: null }], + ['unsafe trade id', { trade_id: Number.MAX_SAFE_INTEGER + 1 }], + ['negative market id', { market_id: -1 }], + ['negative ask id', { ask_id: -1 }], + ['unsafe bid id', { bid_id: Number.MAX_SAFE_INTEGER + 1 }], + ])('drops a WebSocket fill with %s', async (_scenario, overrides) => { + const { provider, clientInstance } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, }); - 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', + clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], }); - 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, + StreamFakeWebSocket.instances = []; + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ callback }); + await provider.getAccountState(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const socket = + StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; + socket.open(); + await new Promise((resolve) => setTimeout(resolve, 0)); + callback.mockClear(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'update/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, + ask_account_pnl: '0', + bid_account_pnl: '0', + taker_position_size_before: '0.001', + taker_position_sign_changed: true, + maker_position_size_before: '0', + maker_position_sign_changed: true, + ...overrides, + }, + ], + }, + }), }); - 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(callback).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: [] }], }); - 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', + 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, l1Address: '0xbbbb' }; + 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, }); - 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'); + 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('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'); - } + 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 await venueSendTx(txType, txInfo); + return realImplementation(call); }, ); - 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', + 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, }); - expect(second.error).toBeUndefined(); - expect(second.success).toBe(true); - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('86000'); + 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('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', + 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; }); - // 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(); + clientInstance.getNextNonce.mockImplementationOnce(() => { + nonceRequested(); + return new Promise((resolve) => { + releaseNonce = resolve; + }); }); - const result = await provider.updatePositionTPSL({ + const writeUnderA = provider.cancelOrder({ + orderId: '555', symbol: 'BTC', - stopLossPrice: '85000', }); + 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); - // NO automatic restore: the failure parks a durable MANUAL state. - expect(result.error).toContain('MANUAL re-establishment'); + expect( + calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(clientInstance.sendTx).not.toHaveBeenCalled(); }); - 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', + it('a paused write never submits after provider disconnect', async () => { + const { provider, clientInstance, calls } = buildProvider({ + configuredAccountIndex: null, }); - 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, + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), ); - const corruptBuilt = buildProvider({ - platformDependencies: infraCorrupt, + 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; }); - const corruptVenue = setupTriggerVenue( - corruptBuilt.clientInstance, - corruptBuilt.bridge, - ); - const corruptResult = await corruptBuilt.provider.updatePositionTPSL({ - symbol: 'BTC', - takeProfitPrice: '110000', + clientInstance.getNextNonce.mockImplementationOnce(() => { + nonceRequested(); + return new Promise((resolve) => { + releaseNonce = resolve; + }); }); - 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({ + const writeUnderA = provider.cancelOrder({ + orderId: '555', 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, + 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, }); - const malformedVenue = setupTriggerVenue( - malformedBuilt.clientInstance, - malformedBuilt.bridge, - ); - const malformedResult = await malformedBuilt.provider.updatePositionTPSL({ - symbol: 'BTC', - takeProfitPrice: '110000', + // No wallet account selected at mount time. + getUserAddressMock.mockImplementation(() => { + throw new Error('NO_ACCOUNT_SELECTED'); }); - 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'), + 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', ); - const writeFailBuilt = buildProvider({ - platformDependencies: infraWriteFail, - }); - const writeFailVenue = setupTriggerVenue( - writeFailBuilt.clientInstance, - writeFailBuilt.bridge, + await new Promise((resolve) => setTimeout(resolve, 0)); + const socketsAfterMismatch = StreamFakeWebSocket.instances.map( + (instance) => instance.sent, ); - 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); + 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('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', + 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, }); - expect(retry.success).toBe(false); - expect(retry.error).toContain('unresolved'); - expect(await provider.getRecoveredDispatches()).toStrictEqual([]); - } finally { - nowSpy.mockRestore(); - } + 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('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; + it('routes no account frame after an unobserved external switch', async () => { + const { provider, clientInstance, getUserAddressMock } = buildProvider({ + webSocketCtor: fakeStreamCtor, + configuredAccountIndex: null, }); - const removeGate = new Promise((resolve) => { - releaseRemove = resolve; + clientInstance.getAccountsByL1Address.mockImplementation( + perAddressLookup(), + ); + StreamFakeWebSocket.instances = []; + const accountCallback = jest.fn(); + const unsubscribe = provider.subscribeToAccount({ + callback: accountCallback, }); - // 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, + 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).toHaveBeenCalledWith( + expect.objectContaining({ totalBalance: '0', providerId: 'lighter' }), ); - (infra.diskCache.setItem as jest.Mock).mockImplementation( - async (key: string, value: string) => { - switchDisk.set(key, value); - }, + expect(accountCallback).not.toHaveBeenCalledWith( + expect.objectContaining({ totalBalance: '9999' }), ); - (infra.diskCache.removeItem as jest.Mock).mockImplementation( - async (key: string) => { - signalRemoveEntered(); - await removeGate; - switchDisk.delete(key); - }, + 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(), ); - 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'); + 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(); }); + }); - 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({ + 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.001', + size: '0', + usdAmount: '5000', 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, + price: '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({ + 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.001', + size: '0', + // $5 at $100k → 0.00005 BTC, below min base 0.0002. + usdAmount: '5', orderType: 'limit', - price: '90000', - leverage: 10, + price: '100000', }); - 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({ + 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: '0.001', - orderType: 'market', - }); - expect(priceResult.isValid).toBe(false); - const priceClose = await price.provider.validateClosePosition({ - symbol: 'BTC', + size: '1', + usdAmount: '-5', + orderType: 'limit', + price: '100000', }); - expect(priceClose.isValid).toBe(false); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Invalid usdAmount'); }); + }); - 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); - }, + 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', ); - // 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'); + // 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('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; + it('rejects a limit close without a price', async () => { + const { provider } = buildProvider(); + const result = await provider.closePosition({ + symbol: 'BTC', + orderType: 'limit', }); - let signalSendEntered = (): void => undefined; - const sendEntered = new Promise((resolve) => { - signalSendEntered = resolve; + 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, }); - 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); - }, + expect(result.success).toBe(true); + const orderCall = calls.find( + (call) => call.function === '_signCreateOrder', ); - const firstPromise = provider.updatePositionTPSL({ + // 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', - stopLossPrice: '85000', + usdAmount: '5000', + maxSlippageBps: 100, + // Fresh venue price is 100000; a 90000 snapshot is >1% away. + priceAtCalculation: 90000, }); - 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(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', }); - 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('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.each([ - ['increases', { position: '0.2', sign: 1 }], - ['flips side', { position: '0.1', sign: -1 }], - ])( - 'aborts with zero create/cancel signatures when the position %s before TP/SL signing', - async (_label, livePosition) => { - const { provider, calls, clientInstance, bridge } = buildProvider(); - const venue = setupTriggerVenue(clientInstance, bridge); - venue.seedTrigger('stop-loss', '80000'); - let accountReads = 0; - clientInstance.getAccountByIndex.mockImplementation(async () => { - accountReads += 1; - return { - code: 200, - accounts: [ - { - ...ACCOUNT, - positions: [ - { - ...ACCOUNT.positions[0], - ...(accountReads >= 3 ? livePosition : {}), - }, - ], - }, - ], - }; - }); + it('rejects unknown transfer types through the public ledger path', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getTransferHistory.mockResolvedValue({ + code: 200, + transfers: [ + { + id: 'bad-transfer', + assetId: 3, + amount: '100.000000', + fee: '0.000000', + timestamp: 1700000004000, + type: 'L2TransferRebate', + fromL1Address: ACCOUNT.l1Address, + toL1Address: ACCOUNT.l1Address, + fromAccountIndex: 28, + toAccountIndex: 999, + txHash: '0xbad', + }, + ], + }); - const result = await provider.updatePositionTPSL({ - symbol: 'BTC', - stopLossPrice: '85000', - }); + await expect(provider.getUserNonFundingLedgerUpdates()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); - expect(result.success).toBe(false); - expect(result.error).toContain('position changed before TP/SL signing'); - expect( - calls.filter( - (call) => - call.function === '_signCreateOrder' || - call.function === '_signCreateGroupedOrders' || - call.function === '_signCancelOrder', - ), - ).toHaveLength(0); - expect(venue.rawTriggers).toHaveLength(1); - expect(venue.rawTriggers[0].triggerPrice).toBe('80000'); - }, - ); + it('rethrows venue integrity failures from every financial-history API', async () => { + const integrityError = new Error( + 'Invalid Lighter venue data: malformed financial history', + ); - it('fails closed without writing an undiscoverable live manual recovery when the index already has 64 keys', async () => { - const infra = createMockInfrastructure(); - const fullIndex = Array.from( - { length: 64 }, - (_, index) => `0xother:${String(index)}:7:ETH`, + const funding = buildProvider(); + funding.clientInstance.getPositionFundings.mockRejectedValue( + integrityError, ); - await infra.diskCache.setItem( - 'lighterTpslManualIndex:testnet', - JSON.stringify(fullIndex), + await expect(funding.provider.getFunding()).rejects.toThrow( + 'Invalid Lighter venue data', ); - const built = buildProvider({ platformDependencies: infra }); - const venue = setupTriggerVenue(built.clientInstance, built.bridge); - venue.seedTrigger('stop-loss', '80000'); - let readsSeen = 0; - const realActive = - built.clientInstance.getActiveOrders.getMockImplementation() as () => Promise; - built.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 built.provider.updatePositionTPSL({ - symbol: 'BTC', - stopLossPrice: '85000', + const ledger = buildProvider(); + ledger.clientInstance.getDepositHistory.mockRejectedValue(integrityError); + await expect( + ledger.provider.getUserNonFundingLedgerUpdates(), + ).rejects.toThrow('Invalid Lighter venue data'); + + const history = buildProvider(); + history.clientInstance.getWithdrawHistory.mockRejectedValue( + integrityError, + ); + await expect(history.provider.getUserHistory()).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }); + + it('honors fill limit, time range, symbol, and selected-account contracts', async () => { + const { provider, clientInstance } = buildProvider(); + const matchingTrade = { + tradeId: 1, + txHash: '0xtrade', + type: 'trade', + marketId: 1, + size: '0.1', + price: '90000', + usdAmount: '9000', + askId: 10, + bidId: 11, + askAccountId: 99, + bidAccountId: 28, + isMakerAsk: false, + timestamp: 1700000000000, + askAccountPnl: '0', + bidAccountPnl: '0', + takerPositionSizeBefore: '0', + makerPositionSizeBefore: '0', + takerPositionSignChanged: true, + makerPositionSignChanged: true, + }; + clientInstance.getTrades + .mockResolvedValueOnce({ + code: 200, + nextCursor: 'page-2', + trades: [{ ...matchingTrade, tradeId: 2, timestamp: 1700000001000 }], + }) + .mockResolvedValue({ code: 200, trades: [matchingTrade] }); + + expect( + await provider.getOrderFills({ + startTime: 1699999999999, + endTime: 1700000000001, + limit: 7, + }), + ).toHaveLength(1); + expect(clientInstance.getTrades).toHaveBeenCalledWith( + 28, + expect.any(String), + { limit: 100, cursor: 'page-2', marketId: undefined }, + ); + expect( + await provider.getOrderFills({ startTime: 1700000000001 }), + ).toStrictEqual([]); + expect(await provider.getOrFetchFills({ symbol: 'ETH' })).toStrictEqual( + [], + ); + + await expect( + provider.getOrderFills({ + user: '0x0000000000000000000000000000000000000001', + }), + ).rejects.toThrow('selected wallet'); + await expect( + provider.getOrderFills({ aggregateByTime: true }), + ).rejects.toThrow('aggregateByTime'); + }); + + it('continues fill pagination across pages sharing the start-time boundary', async () => { + const { provider, clientInstance } = buildProvider(); + const boundaryTrade = { + tradeId: 1, + txHash: '0xtrade', + type: 'trade', + marketId: 1, + size: '0.1', + price: '90000', + usdAmount: '9000', + askId: 10, + bidId: 11, + askAccountId: 99, + bidAccountId: 28, + isMakerAsk: false, + timestamp: 1700000000000, + askAccountPnl: '0', + bidAccountPnl: '0', + takerPositionSizeBefore: '0', + makerPositionSizeBefore: '0', + takerPositionSignChanged: true, + makerPositionSignChanged: true, + }; + clientInstance.getTrades + .mockResolvedValueOnce({ + code: 200, + nextCursor: 'same-timestamp', + trades: [boundaryTrade], + }) + .mockResolvedValueOnce({ + code: 200, + trades: [{ ...boundaryTrade, tradeId: 2 }], + }); + + const fills = await provider.getOrderFills({ + startTime: boundaryTrade.timestamp, }); - const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; - expect(result.success).toBe(false); - expect(result.error).toContain('manual-recovery index is full'); - expect( - JSON.parse( - (await infra.diskCache.getItem( - 'lighterTpslManualIndex:testnet', - )) as string, - ), - ).toHaveLength(64); - expect( - await infra.diskCache.getItem( - `lighterTpslManual:testnet:${settlementKey}`, - ), - ).toBeNull(); - expect( - JSON.parse( - (await infra.diskCache.getItem( - 'lighterTpslJournalIndex:testnet', - )) as string, - ), - ).toContain(settlementKey); + expect(fills.map((fill) => fill.orderId)).toStrictEqual(['11', '11']); + expect(clientInstance.getTrades).toHaveBeenCalledTimes(2); }); - it('retains a startup journal when the 65th manual recovery cannot be indexed', async () => { - const infra = createMockInfrastructure(); - const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; - await infra.diskCache.setItem( - 'lighterTpslManualIndex:testnet', - JSON.stringify( - Array.from( - { length: 64 }, - (_, index) => `0xother:${String(index)}:7:ETH`, - ), - ), + it('surfaces fill-history transport and authentication failures', async () => { + const { provider, clientInstance } = buildProvider(); + clientInstance.getTrades.mockRejectedValue(new Error('auth expired')); + await expect(provider.getOrderFills()).rejects.toThrow('auth expired'); + await expect(provider.getOrFetchFills()).rejects.toThrow('auth expired'); + }); + + it('suppresses deposits until the Lighter bridge call is implemented; withdrawals remain mainnet-only', () => { + // 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([]); + // A caller hint must never override the provider's venue network: + // DepositService currently asks for mainnet routes, and advertising + // one here would fund mainnet while trading remains on testnet. + expect(provider.getDepositRoutes({ isTestnet: false })).toStrictEqual([]); + expect(provider.getWithdrawalRoutes({ isTestnet: false })).toStrictEqual( + [], ); - await infra.diskCache.setItem( - 'lighterTpslJournalIndex:testnet', - JSON.stringify([settlementKey]), + + const { provider: mainnetProvider } = buildProvider({ + isTestnet: false, + }); + expect(mainnetProvider.getDepositRoutes()).toStrictEqual([]); + const [mainnetRoute] = mainnetProvider.getWithdrawalRoutes(); + expect(mainnetRoute.chainId).toBe('eip155:1'); + expect(mainnetRoute.contractAddress).toBe( + '0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7', ); - await infra.diskCache.setItem( - `lighterTpslJournal:testnet:${settlementKey}`, - JSON.stringify({ - version: 2, - recordedAt: 5, - operationId: 'op-v2-full-index', - createdAt: 5, - apiKeyIndex: 7, - intent: 'replace', - phase: 'creating', - priorTriggers: [], - attempts: [], - }), + expect(mainnetRoute.assetId).toContain( + 'erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', ); - const built = buildProvider({ platformDependencies: infra }); - setupTriggerVenue(built.clientInstance, built.bridge); + expect( + mainnetProvider.getDepositRoutes({ isTestnet: true }), + ).toStrictEqual([]); + }); + }); - await built.provider.getOpenOrders(); - for (let attempt = 0; attempt < 40; attempt += 1) { - const recoveryFailure = ( - infra.debugLogger.log as jest.Mock - ).mock.calls.some( - ([message, context]: [string, { error?: string }]) => - message.includes('journal entry recovery failed') && - context.error?.includes('manual-recovery index is full'), - ); - if (recoveryFailure) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 10)); + 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(); + }); - expect(infra.debugLogger.log).toHaveBeenCalledWith( - expect.stringContaining('journal entry recovery failed'), - expect.objectContaining({ - settlementKey, - error: expect.stringContaining('manual-recovery index is full'), - }), + 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 infra.diskCache.getItem( - `lighterTpslManual:testnet:${settlementKey}`, - ), - ).toBeNull(); + await provider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', + }), + ).toStrictEqual({ isValid: true }); expect( - JSON.parse( - (await infra.diskCache.getItem( - 'lighterTpslJournalIndex:testnet', - )) as string, + 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(); + await expect( + provider.calculateMaintenanceMargin({ asset: 'UNKNOWN' }), + ).rejects.toThrow('maintenance margin metadata unavailable'); + // The liquidation preview uses the ISOLATED formula when the caller + // requests (or omits) that supported mode, 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', + }), ), - ).toContain(settlementKey); + ).toBeCloseTo(91.0931, 3); + // short: 100 + (0.1 - 0.012)*100/(1 + 0.012) = 108.6956... expect( - await infra.diskCache.getItem( - `lighterTpslJournal:testnet:${settlementKey}`, + parseFloat( + await provider.calculateLiquidationPrice({ + entryPrice: 100, + leverage: 10, + direction: 'short', + asset: 'BTC', + }), ), - ).not.toBeNull(); - }); - - 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({ + ).toBeCloseTo(108.6957, 3); + await expect( + provider.calculateLiquidationPrice({ + entryPrice: 100, + leverage: 10, + direction: 'long', + asset: 'UNKNOWN', + }), + ).rejects.toThrow('maintenance margin metadata unavailable'); + // Malformed inputs report the explicit zero contract. + expect( + await provider.calculateLiquidationPrice({ + entryPrice: 0, + leverage: 10, + direction: 'long', + }), + ).toBe('0.00'); + await expect( + provider.calculateLiquidationPrice({ + entryPrice: 100, + leverage: 10, + direction: 'long', + marginType: 'cross', + asset: 'BTC', + }), + ).rejects.toThrow('cross-margin liquidation previews'); + expect(await provider.getMaxLeverage('BTC')).toBeGreaterThan(0); + await expect(provider.getMaxLeverage('UNKNOWN')).rejects.toThrow( + 'margin metadata unavailable', + ); + // 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)); + expect(await provider.previewPositionModify({} as never)).toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + }); + + 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', - takeProfitPrice: '110000', - stopLossPrice: '80000', - }); - expect(result.success).toBe(false); - expect(result.error).toContain('client order id'); - expect(calls).toHaveLength(0); - } finally { - randomSpy.mockRestore(); + 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('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 = { + it('returns an explorer URL', () => { + const { provider } = buildProvider(); + expect(provider.getBlockExplorerUrl('0xabc')).toContain('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/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); + built.clientInstance.getAccountByIndex.mockResolvedValue({ code: 200, - orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 100000 }], - }; - clientInstance.getOrderBookDetails.mockImplementation(async () => { - await priceGate; - return details; + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + marginMode: 1, + allocatedMargin: '2000', + }, + ], + }, + ], }); - const placement = provider.placeOrder({ + 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', - isBuy: true, - size: '0.001', - orderType: 'market', + amount: '10', }); - 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); + 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('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'), + it('rejects malformed version-4 recovered-dispatch rows instead of dropping them', async () => { + const infra = createMockInfrastructure(); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [], + recovered: [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:25', + txHash: 'beef', + outcome: 'succeeded', + // Missing evidence makes the v4 safety document malformed. + }, + ], + }), + ); + const built = buildProvider({ platformDependencies: infra }); + + await expect(built.provider.getRecoveredDispatches()).rejects.toThrow( + 'corrupt', ); - 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', + it.each([ + ['succeeded', 'abab000000000042'], + ['unknown', null], + ] as const)( + 'preserves a blocking %s outcome when 32 non-blocking failures already fill the ledger', + async (expectedOutcome, txHash) => { + const infra = createMockInfrastructure(); + const failedOutcomes = Array.from({ length: 32 }, (_, index) => ({ + recoveryId: `${String(index)}:failed`, + kind: 13, + intent: `withdraw:${String(index)}`, + txHash: `dead${String(index).padStart(8, '0')}`, + outcome: 'failed', + evidence: 'tx-status:0', + })); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [ + { + nonce: 42, + txHash, + expiresAt: 9_999_999_999_999, + kind: 13, + intent: 'withdraw:25', + owner: null, + }, + ], + recovered: failedOutcomes, + }), + ); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + setupTriggerVenue(built.clientInstance, built.bridge); + if (expectedOutcome === 'succeeded') { + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: 42, + status: 2, + }); + } else { + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: 43, + }); + } + + const firstRetry = await built.provider.withdraw({ amount: '25' }); + expect(firstRetry.success).toBe(false); + const recovered = await built.provider.getRecoveredDispatches(); + expect(recovered).toHaveLength(32); + expect(recovered.some((row) => row.outcome === expectedOutcome)).toBe( + true, + ); + // The blocking row must survive the disk round-trip, not merely the + // in-memory resolve pass. + const secondRetry = await built.provider.withdraw({ amount: '25' }); + expect(secondRetry.success).toBe(false); + expect(secondRetry.error).toContain( + expectedOutcome === 'succeeded' + ? 'actually completed' + : 'UNKNOWN outcome', + ); + }, + ); + + it('keeps an exact-hash pending transaction unresolved until it reaches a terminal status', async () => { + const registeredKey = '9c'.repeat(40); + const infra = createMockInfrastructure(); + 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, + }, + ], + recovered: [], + }), + ); + const built = buildProvider({ + registeredKey, + platformDependencies: infra, }); - expect(marginExecution.success).toBe(false); - expect(calls).toHaveLength(0); - }); + setupTriggerVenue(built.clientInstance, built.bridge); + let txStatus = 1; + built.clientInstance.getTx.mockImplementation(async () => ({ + code: 200, + hash: 'abab000000000042', + accountIndex: 28, + apiKeyIndex: 7, + nonce: 42, + status: txStatus, + })); - 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', + const pendingRetry = await built.provider.withdraw({ amount: '25' }); + expect(pendingRetry.success).toBe(false); + expect(pendingRetry.error).toContain('unresolved outcome'); + const pendingOutcomes = await built.provider.getRecoveredDispatches(); + expect(pendingOutcomes).toHaveLength(1); + expect(pendingOutcomes[0]).toMatchObject({ + outcome: 'unknown', + acknowledgeable: false, }); - 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); + await expect( + built.provider.acknowledgeRecoveredDispatch( + pendingOutcomes[0].recoveryId, + ), + ).rejects.toThrow('cannot be acknowledged'); + const pendingDoc = JSON.parse( + (await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:7', + )) as string, + ) as { + consumedFloor: number; + entries: unknown[]; + recovered: unknown[]; + }; + expect(pendingDoc.consumedFloor).toBe(0); + expect(pendingDoc.entries).toHaveLength(1); + expect(pendingDoc.recovered).toHaveLength(0); + + txStatus = 2; + const settledRetry = await built.provider.withdraw({ amount: '25' }); + expect(settledRetry.success).toBe(false); + expect(settledRetry.error).toContain('actually completed'); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(1); + await built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId); + expect((await built.provider.withdraw({ amount: '25' })).success).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', + 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, }); - 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', + 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: 2, + } + : null, ); - const cancelAt = calls.findIndex( - (call) => call.function === '_signCancelOrder', + // A blocked write resolves both entries into recovered outcomes. + expect((await built.provider.withdraw({ amount: '5' })).success).toBe( + false, ); - 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'); + 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('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', - }, - ], + 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 }], }); - 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' }; + // 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 realImplementation(call); + return result; }, ); - const result = await provider.updatePositionTPSL({ + const parked = await provider.updatePositionTPSL({ symbol: 'BTC', - stopLossPrice: '85000', + takeProfitPrice: '111000', + stopLossPrice: '81000', }); - 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({ + 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', - size: '1e300', - currentPrice: 100000, + stopLossPrice: '84000', }); - expect(closeValidation.isValid).toBe(false); - expect(closeValidation.error).toContain('integer range'); - const closeExecution = await provider.closePosition({ + 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', - size: '1e300', - currentPrice: 100000, + stopLossPrice: '84000', }); - expect(closeExecution.success).toBe(false); - expect(closeExecution.error).toContain('integer range'); - expect(calls).toHaveLength(0); + 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('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: [ - { - ...BTC_MARKET, - lastTradePrice: reference, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - ], - }); - const buyRequest = { + 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: '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); + orderType: 'limit', + price: '90000', + leverage: 10, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('PARTIAL STATE'); + expect(result.partialState).toStrictEqual({ leverageUpdated: 10 }); }); - 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: [ - { - ...BTC_MARKET, - lastTradePrice: reference, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - ], + 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, + }, }); - // 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({ + second.clientInstance.getAccountByIndex.mockResolvedValue({ code: 200, - accounts: [ - { - ...ACCOUNT, - positions: [ - { ...ACCOUNT.positions[0], position: '0.001', sign: -1 }, - ], - }, - ], + accounts: [{ ...ACCOUNT, index: 99 }], }); - const shortCloseValidation = await provider.validateClosePosition({ + 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)[])[1]); + } + 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:0', + }, + ], + }), + ); + const built = buildProvider({ + registeredKey, + platformDependencies: infra, }); - expect(shortCloseValidation.isValid).toBe(false); - expect(shortCloseValidation.error).toContain('uint32'); - const shortCloseExecution = await provider.closePosition({ + 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', }); - 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({ + 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, - accounts: [ - { - ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], - }, - ], + l1Address: ACCOUNT.l1Address, + subAccounts: [ACCOUNT], }); - const longCloseValidation = await provider.validateClosePosition({ + 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 universal one-tick USD minimum rather than the maker-only floor', async () => { + const { provider } = buildProvider(); + const markets = await provider.getMarkets(); + const btc = markets.find((market) => market.name === 'BTC'); + // Lighter's base/quote floors are maker-only. Market and IOC orders + // may use one base-size tick: 0.00001 BTC x $100000 = $1. + expect(btc?.minimumOrderSize).toBe(1); + 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(longCloseValidation.isValid).toBe(true); - const longCloseExecution = await provider.closePosition({ + expect(validation.isValid).toBe(true); + const result = await built.provider.closePosition({ symbol: 'BTC', + size: '', + orderType: 'market', + currentPrice: 100000, }); - expect(longCloseExecution.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.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({ + describe('signer readiness lock return gap', () => { + it.each(['reset', 'wallet-rebind'] as const)( + 'refuses stale readiness and keeps unfinished journals visible after %s', + async (retirement) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + configuredAccountIndex: null, + }); + const nextAddress = + retirement === 'reset' + ? ACCOUNT.l1Address.toLowerCase() + : '0x1111111111111111111111111111111111111111'; + const nextIndex = retirement === 'reset' ? 28 : 29; + const keys = ['BTC', 'ETH'].map( + (symbol) => `${nextAddress}:${nextIndex}:7:${symbol}`, + ); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify(keys), + ); + for (const [index, settlementKey] of keys.entries()) { + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: `return-gap-${index}`, + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 7, + intent: 'replace', + phase: index === 0 ? 'creating' : 'cancelling', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }), + ); + } + const getItem = jest.spyOn(infra.diskCache, 'getItem'); + const eviction = jest.spyOn(Map.prototype, 'delete'); + const originalDelete = eviction.getMockImplementation(); + if (!originalDelete) { + throw new Error('Missing real mutex eviction implementation'); + } + let retired = false; + let rebound: Promise | undefined; + // The real venue mutex evicts its tail after its critical section + // settles, before the awaiting setupSigner continuation resumes. + // Retire here rather than changing private fields or mocking setup. + eviction.mockImplementation(function ( + this: Map, + key: unknown, + ) { + const deleted = originalDelete.call(this, key); + if (key === 'lighterVenueWrite:testnet:28' && !retired) { + retired = true; + if (retirement === 'reset') { + built.fireReset(); + } else { + built.getUserAddressMock.mockReturnValue(nextAddress); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: nextAddress, + subAccounts: [ + { ...ACCOUNT, index: nextIndex, l1Address: nextAddress }, + ], + }); + rebound = built.provider.getPendingManualRecoveries(); + } + } + return deleted; + }); + try { + const ready = await built.provider.isReadyToTrade(); + await rebound; + expect(retired).toBe(true); + expect(ready.ready).toBe(false); + expect(ready.error).toContain('while this operation was in flight'); + // Only the explicit read used to rebind may read the journal index. + expect( + getItem.mock.calls.filter( + ([key]) => key === 'lighterTpslJournalIndex:testnet', + ), + ).toHaveLength(retirement === 'reset' ? 0 : 1); + const pending = await built.provider.getPendingManualRecoveries(); + expect(pending.map((entry) => entry.settlementKey)).toStrictEqual( + keys, + ); + for (const entry of pending) { + expect(entry.reason).toContain('unfinished'); + expect(entry.actionNeeded).toContain( + 'Initialize the wallet trading key', + ); + } + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.calls.map((call) => call.function)).toStrictEqual([ + '_createClient', + ]); + } finally { + eviction.mockRestore(); + await built.provider.disconnect(); + } + }, + ); + }); + + // Focused public-provider regression: the venue double controls acceptance + // and book visibility independently; the real provider owns slot discovery, + // durable nonce completion, journal recovery and protection serialization. + describe('protection journals across migrated key slots', () => { + it.each([false, true])( + 'blocks replacement=%s after an accepted old-slot create leaves the nonce ledger', + async (replacement) => { + const infra = createMockInfrastructure(); + const original = buildProvider({ + platformDependencies: infra, + apiKeyIndex: 19, + }); + Object.assign(original.bridge, { + getRecoverableKeyIndices: async (): Promise => [19], + }); + original.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 19, publicKey: '9c'.repeat(40) }], + }); + const originalVenue = setupTriggerVenue( + original.clientInstance, + original.bridge, + { apiKeyIndex: 19 }, + ); + originalVenue.setRestLag(100); + const created = await original.provider.updatePositionTPSL({ symbol: 'BTC', - takeProfitPrice: bad, + stopLossPrice: '80000', }); - expect(takeProfit.success).toBe(false); - const stopLoss = await provider.updatePositionTPSL({ + expect(created.success).toBe(false); + expect(created.error).toContain('settlement is not yet visible'); + expect(originalVenue.rawTriggers).toHaveLength(1); + expect(original.clientInstance.sendTx).toHaveBeenCalledTimes(1); + const sourceKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const journalKey = `lighterTpslJournal:testnet:${sourceKey}`; + const journalBefore = await infra.diskCache.getItem(journalKey); + expect(journalBefore).not.toBeNull(); + const pointer = JSON.parse(journalBefore ?? '') as { + operationId: string; + }; + const payloadKey = `lighterTpslJournalOp:testnet:${sourceKey}:${pointer.operationId}`; + const payloadBefore = await infra.diskCache.getItem(payloadKey); + expect(JSON.parse(payloadBefore ?? '')).toMatchObject({ + apiKeyIndex: 19, + intent: 'replace', + phase: 'creating', + attempts: [expect.objectContaining({ kind: 'create' })], + }); + const ledgerBefore = await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:19', + ); + expect(JSON.parse(ledgerBefore ?? '')).toMatchObject({ + entries: [], + recovered: [], + }); + await original.provider.disconnect(); + + const migrated = buildProvider({ platformDependencies: infra }); + Object.assign(migrated.bridge, { + getRecoverableKeyIndices: async (): Promise => [19, 7], + }); + migrated.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 19, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 7, publicKey: '9c'.repeat(40) }, + ], + }); + const migratedVenue = setupTriggerVenue( + migrated.clientInstance, + migrated.bridge, + ); + migratedVenue.rawTriggers.push(...originalVenue.rawTriggers); + migratedVenue.setVenueNonce(originalVenue.getVenueNonce()); + migratedVenue.setNextIndex(originalVenue.getNextIndex()); + migratedVenue.primeLag([], 100); + expect((await migrated.provider.isReadyToTrade()).ready).toBe(true); + expect( + jest.mocked(migrated.bridge).createClient.mock.lastCall, + ).toStrictEqual([expect.objectContaining({ apiKeyIndex: 7 })]); + expect(await migrated.provider.getRecoveredDispatches()).toStrictEqual( + [], + ); + const result = await migrated.provider.updatePositionTPSL({ symbol: 'BTC', - stopLossPrice: bad, + stopLossPrice: replacement ? '85000' : undefined, }); - expect(stopLoss.success).toBe(false); - } - // Zero bridge calls of ANY kind: no signer setup, no cancels, no - // grouped-order signing. - expect(calls).toHaveLength(0); - }); + expect(result.success).toBe(false); + expect(result.error).toContain('original API key slot 19'); + expect(migrated.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + migrated.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(await infra.diskCache.getItem(journalKey)).toBe(journalBefore); + expect(await infra.diskCache.getItem(payloadKey)).toBe(payloadBefore); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:19'), + ).toBe(ledgerBefore); + expect(migratedVenue.rawTriggers).toStrictEqual( + originalVenue.rawTriggers, + ); + await migrated.provider.disconnect(); - 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); - }); + // The original key can still reconcile the same journal and remove + // the now-visible trigger. No slot-7 signature may consume slot-19 + // transaction identity or clear its settlement obligation. + const restored = buildProvider({ + platformDependencies: infra, + apiKeyIndex: 19, + }); + Object.assign(restored.bridge, { + getRecoverableKeyIndices: async (): Promise => [19], + }); + restored.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 19, publicKey: '9c'.repeat(40) }], + }); + const restoredVenue = setupTriggerVenue( + restored.clientInstance, + restored.bridge, + { apiKeyIndex: 19 }, + ); + restoredVenue.rawTriggers.push(...originalVenue.rawTriggers); + restoredVenue.setVenueNonce(originalVenue.getVenueNonce()); + for (const [hash, transaction] of originalVenue.landedTxs) { + restoredVenue.landedTxs.set(hash, transaction); + } + const removed = await restored.provider.updatePositionTPSL({ + symbol: 'BTC', + }); + expect(removed).toMatchObject({ success: true }); + expect( + jest.mocked(restored.bridge).createClient.mock.lastCall, + ).toStrictEqual([expect.objectContaining({ apiKeyIndex: 19 })]); + expect(restoredVenue.rawTriggers).toStrictEqual([]); + expect(restored.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect(restored.clientInstance.sendTx).toHaveBeenCalledWith( + 15, + expect.any(String), + ); + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + await restored.provider.disconnect(); + + const afterRecovery = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const afterVenue = setupTriggerVenue( + afterRecovery.clientInstance, + afterRecovery.bridge, + ); + const changed = await afterRecovery.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: replacement ? '85000' : undefined, + }); + expect(changed).toMatchObject({ success: true }); + expect(afterVenue.rawTriggers).toHaveLength(replacement ? 1 : 0); + expect(afterRecovery.clientInstance.sendTx).toHaveBeenCalledTimes( + replacement ? 1 : 0, + ); + await afterRecovery.provider.disconnect(); + }, + ); + + it.each([ + [false, 'foreground', false], + [true, 'foreground', false], + [false, 'read', false], + [true, 'read', false], + [false, 'read', true], + [true, 'read', true], + [false, 'foreground', true], + [true, 'foreground', true], + ] as const)( + 'settles a visible old-slot create read-only while its signing key stays foreign, replacement=%s via %s prior-survivor=%s', + async (replacement, recoveryMode, hasPrior) => { + const infra = createMockInfrastructure(); + const original = buildProvider({ + platformDependencies: infra, + apiKeyIndex: 19, + }); + Object.assign(original.bridge, { + getRecoverableKeyIndices: async (): Promise => [19], + }); + original.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 19, publicKey: '9c'.repeat(40) }], + }); + const originalVenue = setupTriggerVenue( + original.clientInstance, + original.bridge, + { apiKeyIndex: 19 }, + ); + if (hasPrior) { + originalVenue.seedTrigger('take-profit', '110000'); + } + originalVenue.setRestLag(100); + const created = await original.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '80000', + }); + expect(created.success).toBe(false); + expect(created.error).toContain('settlement is not yet visible'); + expect(originalVenue.rawTriggers).toHaveLength(hasPrior ? 2 : 1); + expect(original.clientInstance.sendTx).toHaveBeenCalledTimes(1); + const sourceKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const journalKey = `lighterTpslJournal:testnet:${sourceKey}`; + const journalBefore = await infra.diskCache.getItem(journalKey); + expect(journalBefore).not.toBeNull(); + const pointer = JSON.parse(journalBefore ?? '') as { + operationId: string; + }; + const payloadKey = `lighterTpslJournalOp:testnet:${sourceKey}:${pointer.operationId}`; + const payloadBefore = await infra.diskCache.getItem(payloadKey); + expect(JSON.parse(payloadBefore ?? '')).toMatchObject({ + apiKeyIndex: 19, + intent: 'replace', + phase: 'creating', + attempts: [expect.objectContaining({ kind: 'create' })], + }); + const ledgerBefore = await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:19', + ); + expect(JSON.parse(ledgerBefore ?? '')).toMatchObject({ + entries: [], + recovered: [], + }); + await original.provider.disconnect(); + + const migrated = buildProvider({ platformDependencies: infra }); + Object.assign(migrated.bridge, { + getRecoverableKeyIndices: async (): Promise => [19, 7], + }); + migrated.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 19, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 7, publicKey: '9c'.repeat(40) }, + ], + }); + const migratedVenue = setupTriggerVenue( + migrated.clientInstance, + migrated.bridge, + ); + migratedVenue.rawTriggers.push(...originalVenue.rawTriggers); + migratedVenue.setVenueNonce(originalVenue.getVenueNonce()); + migratedVenue.setNextIndex(originalVenue.getNextIndex()); + migratedVenue.primeLag([], 100); + expect((await migrated.provider.isReadyToTrade()).ready).toBe(true); + expect( + jest.mocked(migrated.bridge).createClient.mock.lastCall, + ).toStrictEqual([expect.objectContaining({ apiKeyIndex: 7 })]); + expect(await migrated.provider.getRecoveredDispatches()).toStrictEqual( + [], + ); + const result = await migrated.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: replacement ? '85000' : undefined, + }); + expect(result.success).toBe(false); + expect(result.error).toContain('original API key slot 19'); + expect(migrated.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + migrated.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(await infra.diskCache.getItem(journalKey)).toBe(journalBefore); + expect(await infra.diskCache.getItem(payloadKey)).toBe(payloadBefore); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:19'), + ).toBe(ledgerBefore); + expect(migratedVenue.rawTriggers).toStrictEqual( + originalVenue.rawTriggers, + ); + const clientCreationsAtReady = jest.mocked(migrated.bridge).createClient + .mock.calls.length; + // Slot 19 stays foreign permanently. Only its own exact hash/nonce + // identity may discharge the old obligation; the selected key signs + // only the fresh user intent after read-only settlement. + migratedVenue.setRestLag(0); + migratedVenue.primeLag([], 0); + migrated.clientInstance.getTx.mockImplementation( + async (hash: string) => { + const old = originalVenue.landedTxs.get(hash); + return old + ? { code: 200, hash, accountIndex: 28, apiKeyIndex: 19, ...old } + : null; + }, + ); + let readProof: + | { + journal: string | null; + submissions: number; + signatures: string[]; + manual: { + settlementKey: string; + priorIntent: string; + survivingOrderIds: string[]; + }[]; + } + | undefined; + if (recoveryMode === 'read') { + for (let attempt = 0; attempt < 80; attempt += 1) { + await migrated.provider.getOpenOrders(); + if ((await infra.diskCache.getItem(journalKey)) === null) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + readProof = { + journal: await infra.diskCache.getItem(journalKey), + submissions: migrated.clientInstance.sendTx.mock.calls.length, + signatures: migrated.calls + .filter((call) => call.function.startsWith('_sign')) + .map((call) => call.function), + manual: (await migrated.provider.getPendingManualRecoveries()).map( + ({ settlementKey, priorIntent, survivingOrderIds }) => ({ + settlementKey, + priorIntent, + survivingOrderIds, + }), + ), + }; + } + expect(readProof).toStrictEqual( + recoveryMode === 'read' + ? { + journal: null, + submissions: 0, + signatures: [], + manual: hasPrior + ? [ + { + settlementKey: sourceKey, + priorIntent: 'replace', + survivingOrderIds: originalVenue.rawTriggers.map( + (row) => String(row.orderIndex), + ), + }, + ] + : [], + } + : undefined, + ); + expect(migratedVenue.rawTriggers).toStrictEqual( + originalVenue.rawTriggers, + ); + const manualKey = `lighterTpslManual:testnet:${sourceKey}`; + const manualIndexKey = 'lighterTpslManualIndex:testnet'; + let failureProof: + | { + hadWarning: boolean; + hadIndexEntry: boolean; + success: boolean | undefined; + warningUnchanged: boolean; + indexUnchanged: boolean; + } + | undefined; + if (recoveryMode === 'read' && hasPrior) { + const warningBefore = await infra.diskCache.getItem(manualKey); + const indexBefore = await infra.diskCache.getItem(manualIndexKey); + const failed = await migrated.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '-1', + }); + failureProof = { + hadWarning: warningBefore !== null, + hadIndexEntry: ( + JSON.parse(indexBefore ?? '[]') as string[] + ).includes(sourceKey), + success: failed.success, + warningUnchanged: + (await infra.diskCache.getItem(manualKey)) === warningBefore, + indexUnchanged: + (await infra.diskCache.getItem(manualIndexKey)) === indexBefore, + }; + } + expect(failureProof).toStrictEqual( + recoveryMode === 'read' && hasPrior + ? { + hadWarning: true, + hadIndexEntry: true, + success: false, + warningUnchanged: true, + indexUnchanged: true, + } + : undefined, + ); + const cleared = await migrated.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: replacement ? '85000' : undefined, + }); + expect(cleared).toMatchObject({ success: true }); + expect( + await migrated.provider.getPendingManualRecoveries(), + ).toStrictEqual([]); + expect(await infra.diskCache.getItem(journalKey)).toBeNull(); + expect(migratedVenue.rawTriggers).toHaveLength(replacement ? 1 : 0); + expect(migrated.clientInstance.sendTx).toHaveBeenCalledTimes( + (replacement ? 2 : 1) + (hasPrior ? 1 : 0), + ); + expect( + jest + .mocked(migrated.bridge) + .createClient.mock.calls.slice(clientCreationsAtReady) + .every(([call]) => call.apiKeyIndex === 7), + ).toBe(true); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:19'), + ).toBe(ledgerBefore); + await migrated.provider.disconnect(); + expect(await infra.diskCache.getItem(manualKey)).toBeNull(); + expect( + JSON.parse((await infra.diskCache.getItem(manualIndexKey)) ?? '[]'), + ).not.toContain(sourceKey); + const restarted = buildProvider({ platformDependencies: infra }); + Object.assign(restarted.bridge, { + getRecoverableKeyIndices: async (): Promise => [19, 7], + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 19, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 7, publicKey: '9c'.repeat(40) }, + ], + }); + const restartedVenue = setupTriggerVenue( + restarted.clientInstance, + restarted.bridge, + ); + restartedVenue.rawTriggers.push(...migratedVenue.rawTriggers); + restartedVenue.setVenueNonce(migratedVenue.getVenueNonce()); + expect((await restarted.provider.isReadyToTrade()).ready).toBe(true); + expect( + jest.mocked(restarted.bridge).createClient.mock.lastCall, + ).toStrictEqual([expect.objectContaining({ apiKeyIndex: 7 })]); + expect( + await restarted.provider.getPendingManualRecoveries(), + ).toStrictEqual([]); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + await restarted.provider.disconnect(); + }, + ); - 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', + it('preserves a late partial recovery obligation during ordinary migrated-slot cleanup', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - expect(overflow.success).toBe(false); - expect(overflow.error).toContain('integer range'); - const infinite = await provider.updateMargin({ + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const indexKey = 'lighterTpslManualIndex:testnet'; + const bytes = JSON.stringify({ + version: 1, + settlementKey: key, symbol: 'BTC', - amount: 'Infinity', + reason: 'Partial protection needs an explicit successor', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'old-partial-operation', + recordedAt: 5, + partialIntent: { + version: 1, + positionSign: 1, + positionWireSize: 10000, + sizeDecimals: 5, + linkage: 'single', + orders: [ + [ + 1, + 987, + '3000', + '8000000', + 1, + LIGHTER_ORDER_TYPE_STOP_LOSS, + LIGHTER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, + 1, + '8000000', + LIGHTER_ORDER_EXPIRY_NONE, + ], + ], + }, }); - expect(infinite.success).toBe(false); - expect(calls).toHaveLength(0); - }); + const send = built.clientInstance.sendTx.getMockImplementation() as + | LighterClientService['sendTx'] + | undefined; + if (!send) { + throw new Error('Missing venue sender'); + } + built.clientInstance.sendTx.mockImplementation( + async (...args: Parameters) => { + const receipt = await send(...args); + // Earlier-key reconciliation publishes its durable manual obligation + // after this operation has already passed its explicit-intent preflight. + await infra.diskCache.setItem(indexKey, JSON.stringify([key])); + await infra.diskCache.setItem(docKey, bytes); + return receipt; + }, + ); - 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 = { + const result = await built.provider.updatePositionTPSL({ 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, - }, - ], + stopLossPrice: '85000', }); - 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); + expect(result.success).toBe(true); + expect(venue.rawTriggers).toHaveLength(1); + expect(await infra.diskCache.getItem(docKey)).toBe(bytes); + expect(await infra.diskCache.getItem(indexKey)).toBe( + JSON.stringify([key]), + ); + const rows = await built.provider.getPendingManualRecoveries(); + expect(rows).toHaveLength(1); + expect(rows[0].partialIntent?.legs[0].size).toBe('0.03'); + expect(rows[0].actionNeeded).toContain('exact recovery ID'); + await built.provider.disconnect(); }); - 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); - }); - }); + it.each(['wallet', 'account', 'network', 'symbol'] as const)( + 'preserves unrelated previous-slot manual warning bytes for another %s after an explicit update', + async (context) => { + const infra = createMockInfrastructure(); + const address = + context === 'wallet' + ? '0x9999999999999999999999999999999999999999' + : ACCOUNT.l1Address.toLowerCase(); + const key = `${address}:${context === 'account' ? 29 : 28}:19:${context === 'symbol' ? 'ETH' : 'BTC'}`; + const network = context === 'network' ? 'mainnet' : 'testnet'; + const indexKey = `lighterTpslManualIndex:${network}`; + const docKey = `lighterTpslManual:${network}:${key}`; + await infra.diskCache.setItem(indexKey, JSON.stringify([key])); + await infra.diskCache.setItem( + docKey, + 'unrelated bytes must not be interpreted', + ); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + expect( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }), + ).toMatchObject({ success: true }); + expect(venue.rawTriggers).toHaveLength(1); + expect(await infra.diskCache.getItem(docKey)).toBe( + 'unrelated bytes must not be interpreted', + ); + expect(await infra.diskCache.getItem(indexKey)).toBe( + JSON.stringify([key]), + ); + await built.provider.disconnect(); + }, + ); - describe('round-8 market validation parity', () => { - it.each([ - ['limit', undefined], - ['market', undefined], - ['limit', 'IOC'], - ] as const)( - 'rejects an explicit off-grid %s order before signer setup (timeInForce %s)', - async (orderType, timeInForce) => { - const { provider, calls } = buildProvider(); - const request = { + it.each( + ([false, true] as const).flatMap((replacement) => + ( + ['transport', 'foreign', 'incomplete', 'storage', 'corrupt'] as const + ).map((failure) => ({ replacement, failure })), + ), + )( + 'preserves settled update after obsolete cleanup failure $failure replacement=$replacement', + async ({ replacement, failure }) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const indexKey = 'lighterTpslManualIndex:testnet'; + const validBytes = JSON.stringify({ + version: 1, + settlementKey: key, symbol: 'BTC', - isBuy: true, - size: '0.000201', - orderType, - ...(orderType === 'limit' ? { price: '100000' } : {}), - ...(timeInForce ? { timeInForce } : {}), - }; - const validation = await provider.validateOrder(request); - expect(validation.isValid).toBe(false); - expect(validation.error).toContain('size grid'); - const placement = await provider.placeOrder(request); - expect(placement.success).toBe(false); - expect(placement.error).toContain('size grid'); - expect(calls).toHaveLength(0); + reason: 'Prior review', + priorIntent: 'remove', + priorTriggers: [], + survivingOrderIds: ['777'], + operationId: 'prior-operation', + recordedAt: 5, + }); + const bytes = failure === 'corrupt' ? 'corrupt warning' : validBytes; + await infra.diskCache.setItem(indexKey, JSON.stringify([key])); + await infra.diskCache.setItem(docKey, validBytes); + let cleanup = false; + const getItem = jest + .spyOn(infra.diskCache, 'getItem') + .getMockImplementation(); + if (!getItem) { + throw new Error('Missing storage reader'); + } + const read = + built.clientInstance.getActiveOrders.getMockImplementation() as + | (() => Promise<{ code: number; orders: RawTriggerOrder[] }>) + | undefined; + if (!read) { + throw new Error('Missing venue reader'); + } + built.clientInstance.getActiveOrders.mockImplementation(async () => { + if (cleanup && failure === 'transport') { + throw new Error('Cleanup unavailable'); + } + if (cleanup && failure === 'incomplete') { + return { code: 200 }; + } + if (cleanup && failure === 'foreign') { + return { + code: 200, + orders: [ + { ownerAccountIndex: 29, orderIndex: 888, marketIndex: 1 }, + ], + }; + } + return read(); + }); + const removeItem = jest + .spyOn(infra.diskCache, 'removeItem') + .getMockImplementation(); + if (!removeItem) { + throw new Error('Missing storage remover'); + } + jest + .spyOn(infra.diskCache, 'removeItem') + .mockImplementation(async (storageKey) => { + if (failure === 'storage' && storageKey === docKey) { + throw new Error('Cleanup storage unavailable'); + } + await removeItem(storageKey); + if ( + storageKey === + `lighterTpslJournal:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC` + ) { + cleanup = true; + if (failure === 'corrupt') { + await infra.diskCache.setItem(docKey, bytes); + } + } + }); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: replacement ? '85000' : undefined, + }); + expect(cleanup).toBe(true); + expect(result).toStrictEqual({ + success: true, + childOrderIds: replacement + ? venue.rawTriggers.map((order) => String(order.orderIndex)) + : [], + }); + expect(venue.rawTriggers).toHaveLength(replacement ? 1 : 0); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + replacement ? 2 : 1, + ); + expect(await getItem(docKey)).toBe(bytes); + expect(await getItem(indexKey)).toBe(JSON.stringify([key])); + const pending = + failure === 'corrupt' + ? undefined + : await built.provider.getPendingManualRecoveries(); + expect( + pending?.map(({ settlementKey, survivingOrderIds }) => ({ + settlementKey, + survivingOrderIds, + })), + ).toStrictEqual( + failure === 'corrupt' + ? undefined + : [{ settlementKey: key, survivingOrderIds: ['777'] }], + ); + await built.provider.disconnect(); }, ); - it('does not apply maker-only minimums to market orders', async () => { - const { provider, clientInstance, calls } = buildProvider(); - clientInstance.getOrderBookDetails.mockResolvedValue({ - code: 200, - orderBookDetails: [{ symbol: 'BTC', lastTradePrice: 40000 }], + it.each(['09000', '9000.0', '9e3', '9007199254740992'])( + 'retains malformed persisted previous-slot identity %s', + async (orderId) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + venue.seedTrigger('stop-loss', '80000'); + venue.rawTriggers[0].type = 'limit'; + venue.rawTriggers[0].orderIndex = 9000; + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey: key, + symbol: 'BTC', + reason: 'Review', + priorIntent: 'remove', + priorTriggers: [{ orderId }], + survivingOrderIds: [orderId], + operationId: 'prior-operation', + recordedAt: 5, + }); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem(docKey, bytes); + expect( + await built.provider.updatePositionTPSL({ symbol: 'BTC' }), + ).toMatchObject({ success: true }); + expect(await infra.diskCache.getItem(docKey)).toBe(bytes); + expect(venue.rawTriggers[0].orderIndex).toBe(9000); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + await built.provider.disconnect(); + }, + ); + + it('reclaims interrupted previous-slot index pruning after provider restart', async () => { + const infra = createMockInfrastructure(); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const unrelated = `${ACCOUNT.l1Address.toLowerCase()}:28:19:ETH`; + const docKey = `lighterTpslManual:testnet:${key}`; + const indexKey = 'lighterTpslManualIndex:testnet'; + await infra.diskCache.setItem(indexKey, JSON.stringify([key, unrelated])); + await infra.diskCache.setItem( + docKey, + JSON.stringify({ + version: 1, + settlementKey: key, + symbol: 'BTC', + reason: 'Prior review', + priorIntent: 'remove', + priorTriggers: [], + survivingOrderIds: ['777'], + operationId: 'prior-operation', + recordedAt: 5, + }), + ); + const setItem = jest + .spyOn(infra.diskCache, 'setItem') + .getMockImplementation(); + if (!setItem) { + throw new Error('Missing storage writer'); + } + let interrupted = false; + jest + .spyOn(infra.diskCache, 'setItem') + .mockImplementation(async (storageKey, value) => { + if ( + !interrupted && + storageKey === indexKey && + !(JSON.parse(value) as string[]).includes(key) + ) { + interrupted = true; + throw new Error('Interrupted index pruning'); + } + return setItem(storageKey, value); + }); + const first = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - const request = { + setupTriggerVenue(first.clientInstance, first.bridge); + const initial = await first.provider.updatePositionTPSL({ symbol: 'BTC', - isBuy: true, - size: '0.0002', - orderType: 'market' as const, - price: 'Infinity', - }; - const validation = await provider.validateOrder(request); - expect(validation.isValid).toBe(true); - const placement = await provider.placeOrder(request); - expect(placement.success).toBe(true); + }); + expect(interrupted).toBe(true); + expect(await infra.diskCache.getItem(docKey)).toBeNull(); + expect(await infra.diskCache.getItem(indexKey)).toBe( + JSON.stringify([key, unrelated]), + ); + await first.provider.disconnect(); + const restarted = buildProvider({ platformDependencies: infra }); + Object.assign(restarted.bridge, { + getRecoverableKeyIndices: async (): Promise => [19, 7], + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 19, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 7, publicKey: '9c'.repeat(40) }, + ], + }); + setupTriggerVenue(restarted.clientInstance, restarted.bridge); expect( - calls.filter((call) => call.function === '_signCreateOrder'), - ).toHaveLength(1); + await restarted.provider.updatePositionTPSL({ symbol: 'BTC' }), + ).toMatchObject({ success: true }); + expect(await infra.diskCache.getItem(indexKey)).toBe( + JSON.stringify([unrelated]), + ); + expect(initial).toMatchObject({ success: true }); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + restarted.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect( + jest.mocked(restarted.bridge).createClient.mock.lastCall, + ).toStrictEqual([expect.objectContaining({ apiKeyIndex: 7 })]); + await restarted.provider.disconnect(); }); - 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 = { + it.each(['warning', 'journal', 'session'] as const)( + 'preserves new %s during dangling previous-slot index pruning', + async (change) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const indexKey = 'lighterTpslManualIndex:testnet'; + await infra.diskCache.setItem(indexKey, JSON.stringify([key])); + const bytes = JSON.stringify({ + version: 1, + settlementKey: key, 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({ + reason: 'New warning', + priorIntent: 'remove', + priorTriggers: [], + survivingOrderIds: ['777'], + operationId: 'new-operation', + recordedAt: 5, + }); + const journalKey = `lighterTpslJournal:testnet:${key}`; + const pointer = JSON.stringify({ + pointerVersion: 1, + operationId: 'incoming-operation', + }); + const payloadKey = `lighterTpslJournalOp:testnet:${key}:incoming-operation`; + const payload = JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: 'incoming-operation', + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'remove', + phase: 'cancelling', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'cancel', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + orderId: '777', + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'stale', + }, + ], + }); + const getItem = jest + .spyOn(infra.diskCache, 'getItem') + .getMockImplementation(); + if (!getItem) { + throw new Error('Missing storage reader'); + } + let settled = false; + const removeItem = jest + .spyOn(infra.diskCache, 'removeItem') + .getMockImplementation(); + if (!removeItem) { + throw new Error('Missing storage remover'); + } + jest + .spyOn(infra.diskCache, 'removeItem') + .mockImplementation(async (storageKey) => { + await removeItem(storageKey); + if ( + storageKey === + `lighterTpslManual:testnet:${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC` + ) { + settled = true; + } + }); + let reads = 0; + jest + .spyOn(infra.diskCache, 'getItem') + .mockImplementation(async (storageKey) => { + if (settled && storageKey === docKey) { + reads += 1; + if (reads === 2) { + if (change === 'warning') { + await infra.diskCache.setItem(docKey, bytes); + } + if (change === 'journal') { + await infra.diskCache.setItem(payloadKey, payload); + await infra.diskCache.setItem(journalKey, pointer); + } + if (change === 'session') { + await built.provider.disconnect(); + } + } + } + return getItem(storageKey); + }); + const result = await built.provider.updatePositionTPSL({ symbol: 'BTC', - currentPrice: 100000, - priceAtCalculation: snapshot, }); - expect(closeValidation.isValid).toBe(false); - expect(closeValidation.error).toContain('Invalid price snapshot'); - } - }); + expect(settled).toBe(true); + expect(result.success).toBe(change !== 'session'); + expect(await getItem(indexKey)).toBe(JSON.stringify([key])); + expect(await getItem(docKey)).toBe(change === 'warning' ? bytes : null); + expect(await getItem(journalKey)).toBe( + change === 'journal' ? pointer : null, + ); + expect(await getItem(payloadKey)).toBe( + change === 'journal' ? payload : null, + ); + expect(jest.spyOn(infra.debugLogger, 'log')).not.toHaveBeenCalledWith( + '[LighterProvider] Previous-slot warning cleanup remains pending', + expect.anything(), + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + await built.provider.disconnect(); + }, + ); - 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({ + it.each(['survivor', 'prior'] as const)( + 'retains a previous-slot warning while a recorded %s order stays active', + async (recordedAs) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const orderId = venue.seedTrigger('stop-loss', '80000'); + // This plain resting limit is deliberately outside protection selection. + venue.rawTriggers[0].type = 'limit'; + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey: key, symbol: 'BTC', - maxSlippageBps, + reason: 'Review prior protection', + priorIntent: 'remove', + priorTriggers: + recordedAs === 'prior' ? [{ orderId: String(orderId) }] : [], + survivingOrderIds: recordedAs === 'survivor' ? [String(orderId)] : [], + operationId: 'prior-operation', + recordedAt: 5, }); - expect(closeValidation.isValid).toBe(false); - expect(closeValidation.error).toContain('Invalid slippage tolerance'); - } - }); - }); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem(docKey, bytes); + expect( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }), + ).toMatchObject({ success: true }); + expect(await infra.diskCache.getItem(docKey)).toBe(bytes); + expect(await built.provider.getPendingManualRecoveries()).toStrictEqual( + [expect.objectContaining({ settlementKey: key })], + ); + expect( + venue.rawTriggers.some((row) => row.orderIndex === orderId), + ).toBe(true); + await built.provider.disconnect(); + }, + ); - 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; + it.each(['new-warning', 'new-journal'] as const)( + 'preserves a %s arriving during previous-slot warning reconciliation', + async (change) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const journalKey = `lighterTpslJournal:testnet:${key}`; + const doc = { + version: 1, + settlementKey: key, + symbol: 'BTC', + reason: 'Review prior protection', + priorIntent: 'remove', + priorTriggers: [], + survivingOrderIds: ['777'], + operationId: 'prior-operation', + recordedAt: 5, }; - const randomSpy = jest - .spyOn(cryptoObj, 'getRandomValues') - .mockImplementation(fillWith(0)); - try { - const zeroResult = await provider.placeOrder({ + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem(docKey, JSON.stringify(doc)); + const payloadKey = `lighterTpslJournalOp:testnet:${key}:incoming-operation`; + const pointer = JSON.stringify({ + pointerVersion: 1, + operationId: 'incoming-operation', + }); + const payload = JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: 'incoming-operation', + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'remove', + phase: 'cancelling', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'cancel', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + orderId: '777', + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'stale', + }, + ], + }); + let reads = 0; + built.clientInstance.getActiveOrders.mockImplementation(async () => { + reads += 1; + if (reads === 3) { + if (change === 'new-warning') { + await infra.diskCache.setItem( + docKey, + JSON.stringify({ ...doc, operationId: 'new-operation' }), + ); + } else { + await infra.diskCache.setItem(payloadKey, payload); + await infra.diskCache.setItem(journalKey, pointer); + } + } + return { code: 200, orders: [] }; + }); + expect( + (await built.provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect(await infra.diskCache.getItem(docKey)).toBe( + JSON.stringify( + change === 'new-warning' + ? { ...doc, operationId: 'new-operation' } + : doc, + ), + ); + expect(await infra.diskCache.getItem(journalKey)).toBe( + change === 'new-journal' ? pointer : null, + ); + expect(jest.spyOn(infra.debugLogger, 'log')).not.toHaveBeenCalledWith( + '[LighterProvider] Previous-slot warning cleanup remains pending', + expect.anything(), + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + await built.provider.disconnect(); + }, + ); + + it.each(['failed', 'incomplete', 'foreign', 'incomplete-row'] as const)( + 'preserves previous-slot warning when its final order read is %s', + async (readMode) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const docKey = `lighterTpslManual:testnet:${key}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey: key, symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + reason: 'Review prior protection', + priorIntent: 'remove', + priorTriggers: [], + survivingOrderIds: ['777'], + operationId: 'prior-operation', + recordedAt: 5, }); - 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); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem(docKey, bytes); + const read = + built.clientInstance.getActiveOrders.getMockImplementation() as + | (() => Promise<{ code: number; orders: RawTriggerOrder[] }>) + | undefined; + if (!read) { + throw new Error('Expected venue book reader'); + } + let reads = 0; + built.clientInstance.getActiveOrders.mockImplementation(async () => { + reads += 1; + if (reads === 1) { + return read(); + } + if (readMode === 'failed') { + throw new Error('Order transport unavailable'); + } + return readMode === 'incomplete' + ? { code: 200 } + : { + code: 200, + orders: [ + { + ownerAccountIndex: readMode === 'foreign' ? 29 : 28, + orderIndex: readMode === 'foreign' ? 888 : undefined, + marketIndex: 1, + }, + ], + }; + }); + expect( + (await built.provider.updatePositionTPSL({ symbol: 'BTC' })).success, + ).toBe(true); + expect(await infra.diskCache.getItem(docKey)).toBe(bytes); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + await built.provider.disconnect(); + }, + ); - // 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({ + it.each([ + { + context: 'wallet', + title: 'the old-slot journal belongs to another wallet', + }, + { + context: 'account', + title: 'the old-slot journal belongs to another account', + }, + { + context: 'network', + title: 'the old-slot journal belongs to another network', + }, + { + context: 'symbol', + title: 'the old-slot journal belongs to another symbol', + }, + { + context: 'absent', + title: 'a matching old-slot index entry has no journal', + }, + ] as const)( + 'allows protection changes when $title', + async ({ context }) => { + const infra = createMockInfrastructure(); + const address = + context === 'wallet' + ? '0x9999999999999999999999999999999999999999' + : ACCOUNT.l1Address.toLowerCase(); + const sourceKey = `${address}:${context === 'account' ? 29 : 28}:19:${context === 'symbol' ? 'ETH' : 'BTC'}`; + const network = context === 'network' ? 'mainnet' : 'testnet'; + const journalKey = `lighterTpslJournal:${network}:${sourceKey}`; + await infra.diskCache.setItem( + `lighterTpslJournalIndex:${network}`, + JSON.stringify([sourceKey]), + ); + if (context !== 'absent') { + // Unrelated bytes must never be loaded, interpreted or discarded. + await infra.diskCache.setItem(journalKey, 'unrelated journal'); + } + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const result = await built.provider.updatePositionTPSL({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + stopLossPrice: '85000', + }); + expect(result).toMatchObject({ success: true }); + expect(venue.rawTriggers).toHaveLength(1); + expect(await infra.diskCache.getItem(journalKey)).toBe( + context === 'absent' ? null : 'unrelated journal', + ); + expect( + await infra.diskCache.getItem(`lighterTpslJournalIndex:${network}`), + ).toContain(sourceKey); + await built.provider.disconnect(); + }, + ); + + it.each(['corrupt', 'wrong-slot'] as const)( + 'preserves and blocks an old-slot %s journal before any protection signature', + async (invalid) => { + const infra = createMockInfrastructure(); + const sourceKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const bytes = + invalid === 'corrupt' + ? '{' + : JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: 'wrong-slot-create', + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'accepted', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([sourceKey]), + ); + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${sourceKey}`, + bytes, + ); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - expect(first.success).toBe(true); - const second = await provider.placeOrder({ + setupTriggerVenue(built.clientInstance, built.bridge); + const result = await built.provider.updatePositionTPSL({ symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90001', + stopLossPrice: '85000', }); - expect(second.success).toBe(false); - expect(second.error).toContain('client order id'); + expect(result.success).toBe(false); + expect(result.error).toContain( + invalid === 'corrupt' ? 'corrupt' : 'malformed', + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); expect( - calls.filter((call) => call.function === '_signCreateOrder'), - ).toHaveLength(1); - } finally { - randomSpy.mockRestore(); - } - }); + built.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect( + await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${sourceKey}`, + ), + ).toBe(bytes); + await built.provider.disconnect(); + }, + ); + }); - 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', + describe('startup journal slot classification', () => { + it('reports an unverified restored slot as unfinished, without claiming another trading key', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: 'startup-slot-19', + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], }), - ]); - for (const result of results) { - expect(result.success).toBe(true); - } - const ids = calls - .filter((call) => call.function === '_signCreateOrder') - .map((call) => call.params[2]); - 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); - } + ); + const pending = await built.provider.getPendingManualRecoveries(); + expect(pending).toHaveLength(1); + expect(pending[0].reason).toContain('unfinished'); + expect(pending[0].reason).not.toContain('previous trading key'); }); + }); - 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); - }, - ); - 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', + it.each(['creating', 'cancelling'] as const)( + 'keeps same-slot %s journals visible after failed signer setup', + async (phase) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${settlementKey}`, + JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: 'startup-slot-19', + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 7, + intent: 'replace', + phase, + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }), + ); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [], + recovered: [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: 'unknown', + evidence: 'rest-advance', + }, + ], + }), + ); + const ready = await built.provider.isReadyToTrade(); + expect(ready.ready).toBe(false); + const pending = await built.provider.getPendingManualRecoveries(); + expect(pending).toHaveLength(1); + expect(pending[0].reason).toContain('unfinished'); + expect(pending[0].reason).not.toContain('previous trading key'); + }, + ); + describe('recovery inventory for absent and unsupported accounts', () => { + it.each( + (['empty', 'not-found', 'premium'] as const).flatMap((account) => + ( + [ + 'getRecoveredDispatches', + 'getPendingManualRecoveries', + 'reconcileRecoveredDispatches', + ] as const + ).map((method) => ({ account, method })), + ), + )( + 'returns empty $method for expected $account account state without signer work', + async ({ account, method }) => { + const built = buildProvider({ configuredAccountIndex: null }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: + account === 'premium' ? [{ ...ACCOUNT, accountType: 1 }] : [], }); - expect(first.success).toBe(true); - expect(second.success).toBe(true); - const ids = calls - .filter((call) => call.function === '_signCreateOrder') - .map((call) => call.params[2]); - 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(); - } - }); + if (account === 'not-found') { + built.clientInstance.getAccountsByL1Address.mockRejectedValue( + new LighterApiError('account not found', 21100), + ); + } + expect(await built.provider[method]()).toStrictEqual([]); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.clientInstance.getNextNonce).not.toHaveBeenCalled(); + }, + ); - 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; - }, + it.each(['empty', 'premium'] as const)( + 'keeps durable owned obligations visible for %s accounts', + async (account) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + }); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${key}`, + JSON.stringify({ + version: 1, + settlementKey: key, + symbol: 'BTC', + reason: 'prior protection failed', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: ['7'], + operationId: 'original', + recordedAt: 1, + }), + ); + const ledger = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [ + { + nonce: 42, + txHash: 'beef', + expiresAt: 9999999999999, + kind: 13, + intent: 'withdraw:1', + owner: null, + }, + ], + recovered: [], + }); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:7', + ledger, ); - try { - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: + account === 'premium' ? [{ ...ACCOUNT, accountType: 1 }] : [], }); - expect(result.success).toBe(true); - const ids = calls - .filter((call) => call.function === '_signCreateOrder') - .map((call) => call.params[2]); - const third = 0xc0 * 65_536 + 0xc0 * 256 + 0xc0; - expect(ids).toStrictEqual([third * 2 ** 24 + third]); - expect(randomSpy).toHaveBeenCalledTimes(2); - } finally { - randomSpy.mockRestore(); - } - }); + const manuals = await built.provider.getPendingManualRecoveries(); + expect(manuals).toHaveLength(1); + expect(manuals[0].survivingOrderIds).toStrictEqual(['7']); + const dispatches = await built.provider.getRecoveredDispatches(); + expect(dispatches).toHaveLength(1); + expect(dispatches[0].acknowledgeable).toBe(false); + expect( + await built.provider.reconcileRecoveredDispatches(), + ).toStrictEqual(dispatches); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:7'), + ).toBe(ledger); + expect(built.clientInstance.getTx).not.toHaveBeenCalled(); + expect(built.clientInstance.getNextNonce).not.toHaveBeenCalled(); + expect(built.calls).toStrictEqual([]); + }, + ); - 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', + it.each([ + 'getRecoveredDispatches', + 'getPendingManualRecoveries', + 'reconcileRecoveredDispatches', + ] as const)('preserves transport failures from %s', async (method) => { + const built = buildProvider({ configuredAccountIndex: null }); + built.clientInstance.getAccountsByL1Address.mockRejectedValue( + new Error('venue transport unavailable'), ); - 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); + await expect(built.provider[method]()).rejects.toThrow( + 'venue transport unavailable', + ); + expect(built.calls).toStrictEqual([]); }); }); - 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 }], - }, - ], - }); + describe('recovery inventory authority and persistence', () => { + it.each([ + 'getRecoveredDispatches', + 'getPendingManualRecoveries', + 'reconcileRecoveredDispatches', + ] as const)( + 'surfaces local storage failures from absent-account %s', + async (method) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], + }); + jest + .spyOn(infra.diskCache, 'getItem') + .mockRejectedValue(new Error('recovery storage unavailable')); + await expect(built.provider[method]()).rejects.toThrow( + 'recovery storage unavailable', + ); + expect(built.calls).toStrictEqual([]); + }, + ); - it('rejects an off-grid partial dust close instead of rounding it up', 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, + it.each(['unknown type', 'wrong wallet'] as const)( + 'rejects %s recovery metadata without signing', + async (issue) => { + const built = buildProvider({ configuredAccountIndex: null }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [ + { + ...ACCOUNT, + ...(issue === 'unknown type' + ? { accountType: 99 } + : { l1Address: '0x0000000000000000000000000000000000000002' }), + }, + ], + }); + await expect(built.provider.getRecoveredDispatches()).rejects.toThrow( + issue === 'unknown type' ? 'type could not be verified' : 'not owned', + ); + expect(built.calls).toStrictEqual([]); + }, + ); + + it('keeps Premium trading unsupported after a successful local inventory read', async () => { + const built = buildProvider({ configuredAccountIndex: null }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [{ ...ACCOUNT, accountType: 1 }], }); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); + const result = await built.provider.withdraw({ amount: '1' }); expect(result.success).toBe(false); - expect(result.error).toContain('size grid'); - expect(calls).toHaveLength(0); + expect(result.error).toContain('Premium accounts are not supported'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.calls).toStrictEqual([]); }); - it('an exact-size dust market close is not bumped to the maker 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, + it.each([ + 'malformed JSON', + 'invalid identity', + 'duplicate identity', + ] as const)( + 'rejects a %s remembered recovery account index', + async (kind) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + }); + const entries = kind === 'invalid identity' ? [1.5] : [28, 28]; + await infra.diskCache.setItem( + `lighterRecoveryAccounts:testnet:${ACCOUNT.l1Address.toLowerCase()}`, + kind === 'malformed JSON' ? '{' : JSON.stringify(entries), + ); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], + }); + await expect(built.provider.getRecoveredDispatches()).rejects.toThrow( + kind === 'malformed JSON' + ? /JSON|property|position/u + : 'recovery account index is corrupt', + ); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('refuses venue mutation if remembering its recovery account fails', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - expect(result.success).toBe(true); - const orderCall = calls.find( - (call) => call.function === '_signCreateOrder', + setupTriggerVenue(built.clientInstance, built.bridge); + const write = infra.diskCache.setItem.bind(infra.diskCache); + infra.diskCache.setItem = async ( + key: string, + value: string, + ): Promise => { + if (key.startsWith('lighterRecoveryAccounts:')) { + throw new Error('recovery account persistence unavailable'); + } + return write(key, value); + }; + const result = await built.provider.withdraw({ amount: '1' }); + expect(result.success).toBe(false); + expect(result.error).toContain( + 'recovery account persistence unavailable', ); - expect(orderCall?.params[3]).toBe('10'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - it('validateOrder does not apply maker minimums to market opens', async () => { - const { provider } = buildProvider(); - const result = await provider.validateOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.00001', - orderType: 'market', - isFullClose: true, - currentPrice: 90000, + it('preserves nonce-only ambiguous dispatch inventory after restart and venue absence', async () => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - expect(result).toStrictEqual({ isValid: true }); - }); - - it('validateOrder approves a live-verified reduce-only full close like placement', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue( - dustPosition('0.00001'), + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + venue.failResponseOnce(13); + expect((await first.provider.withdraw({ amount: '1' })).success).toBe( + false, ); - const result = await provider.validateOrder({ - symbol: 'BTC', - isBuy: false, - size: '0.00001', - orderType: 'market', - reduceOnly: true, - currentPrice: 90000, + const before = await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:7', + ); + expect(before).not.toBeNull(); + const restarted = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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', + restarted.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], }); + const rows = await restarted.provider.getRecoveredDispatches(); + expect(rows).toHaveLength(1); + expect(rows[0].acknowledgeable).toBe(false); expect( - ( - await provider.validateClosePosition({ - symbol: 'BTC', - usdAmount: '-5', - }) - ).isValid, - ).toBe(false); - expect( - ( - await provider.validateClosePosition({ - symbol: 'BTC', - size: '0', - }) - ).isValid, - ).toBe(false); + await restarted.provider.reconcileRecoveredDispatches(), + ).toStrictEqual(rows); expect( - await provider.validateClosePosition({ symbol: 'BTC' }), - ).toStrictEqual({ isValid: true }); - }); - - it('validateClosePosition agrees with execution on live sizing', async () => { - const { provider, clientInstance } = buildProvider(); - const dust = { + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:7'), + ).toBe(before); + expect(restarted.calls).toStrictEqual([]); + expect(restarted.clientInstance.getTx).not.toHaveBeenCalled(); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + restarted.clientInstance.getAccountsByL1Address.mockResolvedValue({ code: 200, - accounts: [ - { - ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], position: '0.0001' }], - }, - ], - }; - clientInstance.getAccountByIndex.mockResolvedValue(dust); - // Maker minimums do not apply to a market partial close, but explicit - // sizes must still land exactly on the base-size grid. - const partialValidation = await provider.validateClosePosition({ - symbol: 'BTC', - size: '0.000099', - currentPrice: 90000, + subAccounts: [ACCOUNT], + }); + expect(await restarted.provider.getRecoveredDispatches()).toStrictEqual( + rows, + ); + const original = ( + JSON.parse(before ?? '{}') as { + entries: { nonce: number; txHash: string }[]; + } + ).entries[0]; + restarted.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: original.nonce + 1, + }); + restarted.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: original.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: original.nonce, + status: 1, + }); + const retry = await restarted.provider.withdraw({ amount: '1' }); + expect(retry.success).toBe(false); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:7'), + ).toBe(before); + expect(await restarted.provider.getRecoveredDispatches()).toStrictEqual( + rows, + ); + const otherNetwork = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + isTestnet: false, }); - expect(partialValidation.isValid).toBe(false); - expect(partialValidation.error).toContain('size grid'); - const partialExecution = await provider.closePosition({ - symbol: 'BTC', - size: '0.000099', - currentPrice: 90000, + otherNetwork.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], }); - expect(partialExecution.success).toBe(false); - expect(partialExecution.error).toContain('size grid'); - // 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, + await otherNetwork.provider.getRecoveredDispatches(), + ).toStrictEqual([]); + const otherWallet = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, }); - expect(exactExecution.success).toBe(true); + otherWallet.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000002', + ); + otherWallet.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], + }); + expect(await otherWallet.provider.getRecoveredDispatches()).toStrictEqual( + [], + ); }); + }); - 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, + describe('recovery inventory producer authority', () => { + it.each(['withdraw', 'setup'] as const)( + 'rejects a wrong-owner ordinary %s before caching or recording its account', + async (operation) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - expect(result.isValid).toBe(false); - expect(result.error).toContain('Invalid limit price'); - } + setupTriggerVenue(built.clientInstance, built.bridge); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [ + { + ...ACCOUNT, + l1Address: '0x0000000000000000000000000000000000000002', + }, + ], + }); + + const result = + operation === 'withdraw' + ? await built.provider.withdraw({ amount: '1' }) + : await built.provider.isReadyToTrade(); + expect(result.error).toContain('not owned by the selected wallet'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.calls).toStrictEqual([]); + expect( + await infra.diskCache.getItem( + `lighterRecoveryAccounts:testnet:${ACCOUNT.l1Address.toLowerCase()}`, + ), + ).toBeNull(); + + const restarted = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + }); + restarted.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], + }); + expect(await restarted.provider.getRecoveredDispatches()).toStrictEqual( + [], + ); + expect(restarted.calls).toStrictEqual([]); + }, + ); + + it('rejects a configured response for a different account before producer setup', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, index: 29 }], + }); + + const result = await built.provider.withdraw({ amount: '1' }); + expect(result.success).toBe(false); + expect(result.error).toContain('account index does not match'); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.calls).toStrictEqual([]); + expect( + await infra.diskCache.getItem( + `lighterRecoveryAccounts:testnet:${ACCOUNT.l1Address.toLowerCase()}`, + ), + ).toBeNull(); }); - it('uses the fresh venue price for a market close without applying maker minimums', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ + it.each([-1, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid ordinary venue index %s before producer setup', + async (index) => { + const built = buildProvider({ configuredAccountIndex: null }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [{ ...ACCOUNT, index }], + }); + + const result = await built.provider.isReadyToTrade(); + expect(result.ready).toBe(false); + expect(result.error).toContain('account index is invalid'); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it('cannot populate cached recovery authority through a wrong-owner ordinary read', async () => { + const built = buildProvider({ configuredAccountIndex: null }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ code: 200, - accounts: [ + subAccounts: [ { ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + l1Address: '0x0000000000000000000000000000000000000002', }, ], }); - const result = await provider.validateClosePosition({ - symbol: 'BTC', - size: '0.00005', - currentPrice: 1_000_000, - }); - expect(result.isValid).toBe(true); - const execution = await provider.closePosition({ - symbol: 'BTC', - size: '0.00005', - currentPrice: 1_000_000, - }); - expect(execution.success).toBe(true); + + await expect(built.provider.getAccountState()).rejects.toThrow( + 'not owned by the selected wallet', + ); + await expect(built.provider.getRecoveredDispatches()).rejects.toThrow( + 'not owned by the selected wallet', + ); + expect(built.clientInstance.getAccountsByL1Address).toHaveBeenCalledTimes( + 2, + ); + expect(built.calls).toStrictEqual([]); }); - it('fails market close validation closed when the fresh venue price is missing or zero', async () => { - for (const orderBookDetails of [ - [ - { - ...BTC_MARKET, - lastTradePrice: undefined, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - ], - [ - { - ...BTC_MARKET, - lastTradePrice: 0, - minInitialMarginFraction: 200, - maintenanceMarginFraction: 120, - }, - ], - ]) { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ + it.each(['absent', 'premium'] as const)( + 'does not advertise acknowledgment of recovered outcomes for %s accounts', + async (account) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + configuredAccountIndex: null, + platformDependencies: infra, + }); + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ledger = JSON.stringify({ + version: 4, + consumedFloor: 42, + entries: [], + recovered: [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: 'failed', + evidence: 'tx-status:0', + }, + ], + }); + await infra.diskCache.setItem(ledgerKey, ledger); + await infra.diskCache.setItem( + `lighterRecoveryAccounts:testnet:${ACCOUNT.l1Address.toLowerCase()}`, + JSON.stringify([28]), + ); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ code: 200, - accounts: [ + subAccounts: + account === 'premium' ? [{ ...ACCOUNT, accountType: 1 }] : [], + }); + + const rows = await built.provider.getRecoveredDispatches(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + outcome: 'failed', + evidence: 'tx-status:0', + acknowledgeable: false, + }); + expect( + await built.provider.reconcileRecoveredDispatches(), + ).toStrictEqual(rows); + await expect( + built.provider.acknowledgeRecoveredDispatch(rows[0].recoveryId), + ).rejects.toThrow( + account === 'premium' + ? 'Premium accounts' + : 'No Lighter account exists', + ); + expect(await infra.diskCache.getItem(ledgerKey)).toBe(ledger); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.clientInstance.getTx).not.toHaveBeenCalled(); + }, + ); + }); + + describe('account-scoped recovered outcomes', () => { + it.each(['hash', 'account', 'slot', 'nonce', 'wallet-switch'])( + 'refuses previous-slot reconciliation with mismatched %s identity', + async (field) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const original = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [ + { + nonce: 42, + txHash: 'beef', + expiresAt: 9999999999999, + kind: 13, + intent: 'withdraw:1', + owner: null, + }, + ], + recovered: [], + }); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:19', + original, + ); + built.clientInstance.getTx.mockImplementation(async () => { + if (field === 'wallet-switch') { + built.getUserAddressMock.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + } + return { + code: 200, + hash: field === 'hash' ? 'different' : 'beef', + accountIndex: field === 'account' ? 29 : 28, + apiKeyIndex: field === 'slot' ? 20 : 19, + nonce: field === 'nonce' ? 43 : 42, + status: 2, + }; + }); + expect((await built.provider.isReadyToTrade()).ready).toBe(true); + built.clientInstance.sendTx.mockClear(); + expect((await built.provider.withdraw({ amount: '1' })).success).toBe( + false, + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:19'), + ).toBe(original); + }, + ); + + it.each([0, 2, 1])( + 'reconciles previous-slot exact transaction status %s without blind retry', + async (status) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:19', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [ + { + nonce: 42, + txHash: 'beef', + expiresAt: 9999999999999, + kind: 13, + intent: 'withdraw:1', + owner: null, + }, + ], + recovered: [], + }), + ); + built.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: 'beef', + accountIndex: 28, + apiKeyIndex: 19, + nonce: 42, + status, + }); + const result = await built.provider.withdraw({ amount: '1' }); + expect(result.success).toBe(status === 0); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + apiKeyIndex: 19, + outcome: + ({ 0: 'failed', 2: 'succeeded' } as Record)[ + status + ] ?? 'unknown', + }); + expect(outcomes[0].acknowledgeable).toBe( + status === 1 ? false : undefined, + ); + }, + ); + + it.each(['network', 'account'])( + 'does not gate an unrelated %s on local dispatch obligations', + async (scope) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const key = + scope === 'network' + ? 'lighterNonceLedger:mainnet:28:19' + : 'lighterNonceLedger:testnet:29:19'; + const original = JSON.stringify({ + version: 4, + consumedFloor: 43, + entries: [], + recovered: [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: 'unknown', + evidence: 'rest-advance', + }, + ], + }); + await infra.diskCache.setItem(key, original); + expect((await built.provider.withdraw({ amount: '1' })).success).toBe( + true, + ); + expect(await infra.diskCache.getItem(key)).toBe(original); + }, + ); + + it.each([ + 'succeeded', + 'unknown', + 'pending', + 'failed', + 'never-landed', + ] as const)( + 'fences migrated financial writes for previous-slot %s obligations', + async (state) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const entry = { + nonce: 42, + txHash: 'beef', + expiresAt: state === 'never-landed' ? 1 : 9999999999999, + kind: 13, + intent: 'withdraw:1', + owner: null, + }; + const original = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: + state === 'pending' || state === 'never-landed' ? [entry] : [], + recovered: + state === 'pending' || state === 'never-landed' + ? [] + : [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: state, + evidence: 'tx-status:2', + }, + ], + }); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:19', + original, + ); + built.clientInstance.getTx.mockResolvedValue( + state === 'pending' + ? { + code: 200, + hash: 'beef', + accountIndex: 28, + apiKeyIndex: 19, + nonce: 42, + status: 1, + } + : null, + ); + expect((await built.provider.isReadyToTrade()).ready).toBe(true); + built.clientInstance.sendTx.mockClear(); + const result = await built.provider.withdraw({ amount: '1' }); + const permitted = state === 'failed' || state === 'never-landed'; + expect(result.success).toBe(permitted); + expect(built.clientInstance.sendTx.mock.calls.length > 0).toBe( + permitted, + ); + const after = await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:19', + ); + expect(JSON.parse(after ?? 'null')).toStrictEqual( + state === 'never-landed' + ? { version: 4, consumedFloor: 0, entries: [], recovered: [] } + : JSON.parse(original), + ); + }, + ); + + it.each(['succeeded', 'unknown'] as const)( + 'releases the financial fence after scoped acknowledgment of a previous-slot %s outcome', + async (outcome) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const recovery = { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome, + evidence: 'tx-status:2', + }; + const failed = { + ...recovery, + recoveryId: '41:dead', + txHash: 'dead', + outcome: 'failed', + }; + const key = 'lighterNonceLedger:testnet:28:19'; + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 4, + consumedFloor: 43, + entries: [], + recovered: [recovery, failed], + }), + ); + const unrelatedKey = 'lighterNonceLedger:testnet:29:19'; + const unrelated = JSON.stringify({ + version: 4, + consumedFloor: 43, + entries: [ { - ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + nonce: 43, + txHash: 'pending', + expiresAt: 9999999999999, + kind: 13, + intent: 'withdraw:1', + owner: null, }, ], + recovered: [recovery], }); - 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, + await infra.diskCache.setItem(unrelatedKey, unrelated); + expect((await built.provider.withdraw({ amount: '1' })).success).toBe( + false, + ); + const listed = await built.provider.getRecoveredDispatches(); + const resolved = listed.find( + (row) => row.apiKeyIndex === 19 && row.outcome === outcome, + ); + expect(resolved).toBeDefined(); + if (!resolved) { + throw new Error('Expected previous-slot recovered outcome'); + } + await built.provider.acknowledgeRecoveredDispatch(resolved.recoveryId); + built.clientInstance.sendTx.mockClear(); + (built.bridge.execute as jest.Mock).mockClear(); + expect((await built.provider.withdraw({ amount: '1' })).success).toBe( + true, + ); + expect((built.bridge.execute as jest.Mock).mock.calls).toContainEqual([ + expect.objectContaining({ function: '_signWithdraw' }), + ]); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(1); + expect( + JSON.parse((await infra.diskCache.getItem(key)) ?? 'null'), + ).toStrictEqual({ + version: 4, + consumedFloor: 43, + entries: [], + recovered: [failed], }); - expect(execution.success).toBe(false); - expect(execution.error).toContain('No live venue price available'); - } - }); + expect(await infra.diskCache.getItem(unrelatedKey)).toBe(unrelated); + }, + ); - 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: [ + it('lists previous-slot outcomes and acknowledges only the exact scoped slot', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const outcome = { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: 'unknown', + evidence: 'rest-advance', + }; + const original = JSON.stringify({ + version: 4, + consumedFloor: 43, + entries: [ { - ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + nonce: 43, + txHash: 'pending-hash', + expiresAt: 9999999999999, + kind: 14, + intent: 'order:BTC', + owner: null, }, ], + recovered: [outcome], }); - // 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'); + for (const slot of [7, 19]) { + await infra.diskCache.setItem( + `lighterNonceLedger:testnet:28:${slot}`, + original, + ); + } + await infra.diskCache.setItem( + 'lighterNonceLedger:mainnet:28:19', + original, + ); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:29:19', + original, + ); + const listed = await built.provider.getRecoveredDispatches(); + const pending = listed.filter((row) => row.acknowledgeable === false); + expect(pending).toHaveLength(2); + await expect( + built.provider.acknowledgeRecoveredDispatch(pending[1].recoveryId), + ).rejects.toThrow('cannot be acknowledged'); + const outcomes = listed.filter((row) => row.acknowledgeable !== false); + expect(outcomes).toHaveLength(2); + expect(new Set(outcomes.map((row) => row.recoveryId)).size).toBe(2); + expect(outcomes.map((row) => row.apiKeyIndex)).toStrictEqual([7, 19]); 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, + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:19'), + ).toBe(original); + await expect( + built.provider.acknowledgeRecoveredDispatch('42:beef'), + ).rejects.toThrow('No pending recovered'); + const restarted = buildProvider({ platformDependencies: infra }); + expect(await restarted.provider.getRecoveredDispatches()).toStrictEqual( + listed, + ); + const otherNetwork = buildProvider({ + platformDependencies: infra, + isTestnet: false, }); - 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, + await expect( + otherNetwork.provider.acknowledgeRecoveredDispatch( + outcomes[1].recoveryId, + ), + ).rejects.toThrow('No pending recovered'); + const otherAccount = buildProvider({ + platformDependencies: infra, + configuredAccountIndex: 29, }); - // The bound wallet resolves to a Premium account. - clientInstance.getAccountsByL1Address.mockResolvedValue({ + otherAccount.clientInstance.getAccountByIndex.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, + accounts: [{ ...ACCOUNT, index: 29 }], }); - 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: { - nonce: 10, - bids: [ - { price: '90000', size: '0.5' }, - { price: '89990', size: '1.5' }, - ], - asks: [ - { price: '90010', size: '0.4' }, - { price: '90020', size: '2.0' }, - ], + await expect( + otherAccount.provider.acknowledgeRecoveredDispatch( + outcomes[1].recoveryId, + ), + ).rejects.toThrow('No pending recovered'); + built.getUserAddressMock.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + await expect( + built.provider.acknowledgeRecoveredDispatch(outcomes[1].recoveryId), + ).rejects.toThrow('not owned by the selected wallet'); + built.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + await built.provider.acknowledgeRecoveredDispatch(outcomes[1].recoveryId); + expect( + JSON.parse( + (await infra.diskCache.getItem('lighterNonceLedger:testnet:28:19')) ?? + 'null', + ), + ).toStrictEqual({ + version: 4, + consumedFloor: 43, + entries: [ + { + nonce: 43, + txHash: 'pending-hash', + expiresAt: 9999999999999, + kind: 14, + intent: 'order:BTC', + owner: null, }, - }), + ], + recovered: [], }); - 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']); + (await built.provider.getRecoveredDispatches()) + .filter((row) => row.acknowledgeable !== false) + .map((row) => row.apiKeyIndex), + ).toStrictEqual([7]); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:7'), + ).toBe(original); 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(); + await infra.diskCache.getItem('lighterNonceLedger:mainnet:28:19'), + ).toBe(original); + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:29:19'), + ).toBe(original); + await expect( + built.provider.acknowledgeRecoveredDispatch(outcomes[1].recoveryId), + ).rejects.toThrow('No pending recovered'); }); - it('rejects a malformed order book frame without emitting or poisoning its cached nonce', async () => { - const { provider } = buildProvider({ webSocketCtor: fakeStreamCtor }); - const bookCallback = jest.fn(); - const unsubscribe = provider.subscribeToOrderBook({ - symbol: 'BTC', - callback: bookCallback, + it('waits for failed candidate selection before exposing account-local recovery state', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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: { - nonce: 10, - bids: [{ price: '90000', size: '0.5' }], - asks: [{ price: '90010', size: '0.4' }], - }, - }), + const entered = createDeferred(); + const release = createDeferred(); + Object.assign(built.bridge, { + getRecoverableKeyIndices: async (): Promise => { + entered.resolve(); + await release.promise; + throw new Error('selection failed'); + }, }); - bookCallback.mockClear(); - socket.onmessage?.({ - data: JSON.stringify({ - type: 'update/order_book', - channel: 'order_book:1', - order_book: { - begin_nonce: 10, - nonce: 11, - bids: [{ price: '89990', size: '12.5oops' }], - asks: [], - }, - }), + const setup = built.provider.isReadyToTrade(); + await entered.promise; + let completed = false; + const read = built.provider.getRecoveredDispatches().then((outcomes) => { + completed = true; + return outcomes; }); - expect(bookCallback).not.toHaveBeenCalled(); + await new Promise((resolveTick) => setImmediate(resolveTick)); + expect(completed).toBe(false); + release.resolve(); + expect((await setup).ready).toBe(false); + expect(await read).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); - socket.onmessage?.({ - data: JSON.stringify({ - type: 'update/order_book', - channel: 'order_book:1', - order_book: { - begin_nonce: 10, - nonce: 12, - bids: [{ price: '89990', size: '1.5' }], - asks: [], + it('keeps previous-slot quarantine visible after selecting a different venue key', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + Object.assign(built.bridge, { + getRecoverableKeyIndices: async (): Promise => [19, 7], + }); + built.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { apiKeyIndex: 19, publicKey: 'ab'.repeat(40) }, + { apiKeyIndex: 7, publicKey: '9c'.repeat(40) }, + ], + }); + const original = JSON.stringify({ + version: 4, + consumedFloor: 43, + entries: [], + recovered: [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: 'unknown', + evidence: 'rest-advance', }, - }), + ], }); - expect(bookCallback).toHaveBeenCalledTimes(1); - const book = bookCallback.mock.calls[0][0]; - expect(book.bids).toStrictEqual([ - expect.objectContaining({ price: '90000', size: '0.5' }), - expect.objectContaining({ price: '89990', size: '1.5' }), - ]); - unsubscribe(); + await infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:19', + original, + ); + expect((await built.provider.isReadyToTrade()).ready).toBe(true); + const outcomes = await built.provider.getRecoveredDispatches(); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + apiKeyIndex: 19, + outcome: 'unknown', + }); + await built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId); + expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); + }); - it('discards an order book after a nonce gap and requests a fresh snapshot', async () => { - const { provider } = buildProvider({ webSocketCtor: fakeStreamCtor }); - const callback = jest.fn(); - const unsubscribe = provider.subscribeToOrderBook({ - symbol: 'BTC', - callback, + describe('recovered-slot ledger binding', () => { + it.each(['append', 'outcome'] as const)( + 'retains slot 19 during an account switch at %s', + async (boundary) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + registeredKey: '9c'.repeat(40), + platformDependencies: infra, + }); + setupTriggerVenue(built.clientInstance, built.bridge); + Object.assign(built.bridge, { + getStoredKeyIndices: async (): Promise => [19], + }); + built.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: 19, publicKey: '9c'.repeat(40) }], + }); + let switched = false; + const switchAccount = (): void => { + switched = true; + built.getUserAddressMock.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + }; + if (boundary === 'append') { + const realSet = ( + infra.diskCache.setItem as jest.Mock + ).getMockImplementation() as ( + key: string, + value: string, + ) => Promise; + jest + .spyOn(infra.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + await realSet(key, value); + if ( + key === 'lighterNonceLedger:testnet:28:19' && + (JSON.parse(value) as { entries: unknown[] }).entries.length > + 0 && + !switched + ) { + switchAccount(); + await built.provider.getAccountState().catch(() => undefined); + } + }); + } else { + const realSend = + built.clientInstance.sendTx.getMockImplementation() as ( + type: number, + info: string, + ) => Promise; + built.clientInstance.sendTx.mockImplementation( + async (type: number, info: string) => { + const response = await realSend(type, info); + if (!switched) { + switchAccount(); + } + return response; + }, + ); + } + const result = await built.provider.withdraw({ amount: '25' }); + expect(result.success).toBe(false); + const doc = JSON.parse( + (await infra.diskCache.getItem( + 'lighterNonceLedger:testnet:28:19', + )) as string, + ) as { entries: unknown[]; recovered: { outcome: string }[] }; + expect( + await infra.diskCache.getItem('lighterNonceLedger:testnet:28:7'), + ).toBeNull(); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + boundary === 'append' ? 0 : 1, + ); + expect(doc.entries).toHaveLength(boundary === 'append' ? 1 : 0); + expect(doc.recovered).toHaveLength(boundary === 'append' ? 0 : 1); + expect(doc.recovered[0]?.outcome).toBe( + boundary === 'append' ? undefined : 'succeeded', + ); + }, + ); + }); + + 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, }); - 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: { - nonce: 10, - bids: [{ price: '90000', size: '1' }], - asks: [{ price: '90010', size: '1' }], - }, - }), + 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], }); - expect(callback).toHaveBeenCalledTimes(1); + 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'), + ); + }); + }); + describe('non-financial recovered dispatch reconciliation', () => { + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const entry = { + nonce: 42, + txHash: 'beef', + expiresAt: 9999999999999, + kind: 13, + intent: 'withdraw:1', + owner: null, + }; + const terminal = { + code: 200, + hash: 'beef', + accountIndex: 28, + apiKeyIndex: 7, + nonce: 42, + status: 2, + }; + const oldOutcome = { + recoveryId: '40:old', + kind: 13, + intent: 'withdraw:old', + txHash: 'old', + outcome: 'unknown', + evidence: 'rest-advance', + }; - socket.onmessage?.({ - data: JSON.stringify({ - type: 'update/order_book', - channel: 'order_book:1', - order_book: { - begin_nonce: 12, - nonce: 13, - bids: [{ price: '90000', size: '0' }], - asks: [{ price: '90020', size: '2' }], - }, + const parseLedger = ( + raw: string | null, + ): { consumedFloor: number; entries: unknown[]; recovered: unknown[] } => + JSON.parse(raw ?? '{}') as { + consumedFloor: number; + entries: unknown[]; + recovered: unknown[]; + }; + + async function fixture(): Promise< + BuiltProvider & { infra: ReturnType } + > { + const infra = createMockInfrastructure(); + const built = buildProvider({ platformDependencies: infra }); + await infra.diskCache.setItem( + ledgerKey, + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [entry], + recovered: [], }), - }); + ); + built.clientInstance.getTx.mockResolvedValue(terminal); + return { ...built, infra }; + } - expect(callback).toHaveBeenCalledTimes(1); - expect(socket.sent).toContainEqual( - JSON.stringify({ type: 'unsubscribe', channel: 'order_book/1' }), + it.each([ + ['executed', terminal, 'succeeded'], + ['rejected', { ...terminal, status: 0 }, 'failed'], + ['pending', { ...terminal, status: 1 }, 'unknown'], + ['unknown numeric status', { ...terminal, status: 999 }, 'unknown'], + ['missing status', { ...terminal, status: undefined }, 'unknown'], + ['string status', { ...terminal, status: '2' }, 'unknown'], + ['wrong hash', { ...terminal, hash: 'different' }, 'unknown'], + ['wrong account', { ...terminal, accountIndex: 99 }, 'unknown'], + ['wrong slot', { ...terminal, apiKeyIndex: 19 }, 'unknown'], + ['wrong nonce', { ...terminal, nonce: 43 }, 'unknown'], + ])( + 'reconciles %s without signer or financial activity', + async (_name, response, outcome) => { + const built = await fixture(); + built.clientInstance.getTx.mockResolvedValue(response); + const rows = await built.provider.reconcileRecoveredDispatches(); + const resolved = outcome !== 'unknown'; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ outcome, apiKeyIndex: 7 }); + expect(rows[0].acknowledgeable).toBe(resolved ? undefined : false); + const doc = parseLedger( + (await built.infra.diskCache.getItem(ledgerKey)) ?? '{}', + ); + expect(doc.entries).toStrictEqual(resolved ? [] : [entry]); + expect(doc.consumedFloor).toBe(resolved ? 43 : 0); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.clientInstance.getApiKeys).not.toHaveBeenCalled(); + expect(built.clientInstance.getActiveOrders).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['hashless consumed', null, 43, 9999999999999, 1, 'unknown'], + ['hashless unconsumed', null, 42, 1, 1, 'unknown'], + ['absent unexpired', 'beef', 42, 9999999999999, 1, 'unknown'], + ['absent expired', 'beef', 42, 1, 0, undefined], + ['absent consumed', 'beef', 43, 9999999999999, 0, undefined], + ])( + 'handles %s using exact nonce and validity evidence', + async (_name, hash, nonce, expiry, count, outcome) => { + const built = await fixture(); + await built.infra.diskCache.setItem( + ledgerKey, + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [{ ...entry, txHash: hash, expiresAt: expiry }], + recovered: [], + }), + ); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce, + }); + built.clientInstance.getTx.mockResolvedValue(null); + const rows = await built.provider.reconcileRecoveredDispatches(); + expect(rows).toHaveLength(count); + expect(rows[0]?.outcome).toBe(outcome); + const doc = parseLedger( + (await built.infra.diskCache.getItem(ledgerKey)) ?? '{}', + ); + expect(doc.consumedFloor).toBe(nonce === 43 ? 43 : 0); + expect(doc.entries).toHaveLength( + (hash === null && nonce === 42) || + (hash !== null && nonce === 42 && expiry !== 1) + ? 1 + : 0, + ); + expect(built.calls).toStrictEqual([]); + }, + ); + + it.each([undefined, -1, Number.NaN, Infinity, '43'])( + 'rejects malformed venue nonce %s without changing the ledger', + async (nonce) => { + const built = await fixture(); + const before = await built.infra.diskCache.getItem(ledgerKey); + built.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce, + }); + await expect( + built.provider.reconcileRecoveredDispatches(), + ).rejects.toThrow('invalid venue nonce'); + expect(await built.infra.diskCache.getItem(ledgerKey)).toBe(before); + expect(built.clientInstance.getTx).not.toHaveBeenCalled(); + }, + ); + + it('retains lookup errors as pending, then reconciles a later exact success', async () => { + const built = await fixture(); + const before = await built.infra.diskCache.getItem(ledgerKey); + built.clientInstance.getTx.mockRejectedValueOnce(new Error('HTTP 503')); + expect(await built.provider.reconcileRecoveredDispatches()).toStrictEqual( + [expect.objectContaining({ acknowledgeable: false })], ); - expect(socket.sent).toContainEqual( - JSON.stringify({ type: 'subscribe', channel: 'order_book/1' }), + expect(await built.infra.diskCache.getItem(ledgerKey)).toBe(before); + expect(await built.provider.reconcileRecoveredDispatches()).toStrictEqual( + [expect.objectContaining({ outcome: 'succeeded' })], ); - 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 - { - t: 1600, - o: null as unknown as number, - h: 2, - l: 0.5, - c: 1.5, - v: 10, - }, - { t: 1700, o: '', h: 2, l: 0.5, c: 1.5, v: 10 }, - { - t: 1800, - o: false as unknown as number, - h: 2, - l: 0.5, - c: 1.5, - v: 10, - }, - ], - }); - const candleCallback = jest.fn(); - const unsubscribe = provider.subscribeToCandles({ - symbol: 'BTC', - interval: '1h', - callback: candleCallback, + it('continues across quarantines and ambiguous slots, preserving owned entries and journals', async () => { + const built = await fixture(); + const owned = { ...entry, nonce: 44, txHash: 'owned', owner: 'journal' }; + const ownedKey = 'lighterNonceLedger:testnet:28:20'; + const ownedBytes = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [owned], + recovered: [], }); - 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 }, - { t: 4000, o: null, h: 2, l: 1, c: 2, v: 5 }, - { t: 5000, o: '', h: 2, l: 1, c: 2, v: 5 }, - { t: 6000, o: false, h: 2, l: 1, c: 2, v: 5 }, - ], + await built.infra.diskCache.setItem( + ledgerKey, + JSON.stringify({ + version: 4, + consumedFloor: 41, + entries: [entry, owned], + recovered: [oldOutcome], }), - }); - const live = - candleCallback.mock.calls[candleCallback.mock.calls.length - 1][0]; + ); + await built.infra.diskCache.setItem( + 'lighterNonceLedger:testnet:28:19', + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [{ ...entry, txHash: 'later' }], + recovered: [], + }), + ); + await built.infra.diskCache.setItem(ownedKey, ownedBytes); + await built.infra.diskCache.setItem( + 'lighterTpslJournal:testnet:journal', + 'untouched', + ); + built.clientInstance.getTx.mockImplementation(async (hash: string) => + hash === 'later' + ? { ...terminal, hash, apiKeyIndex: 19 } + : { ...terminal, status: 1 }, + ); + const rows = await built.provider.reconcileRecoveredDispatches(); + expect(rows).toHaveLength(5); + expect(rows).toContainEqual( + expect.objectContaining({ outcome: 'succeeded', apiKeyIndex: 19 }), + ); + expect(rows).toContainEqual( + expect.objectContaining({ + outcome: 'unknown', + evidence: 'rest-advance', + }), + ); + expect(await built.infra.diskCache.getItem(ownedKey)).toBe(ownedBytes); 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); + await built.infra.diskCache.getItem( + 'lighterTpslJournal:testnet:journal', + ), + ).toBe('untouched'); + expect( + (built.clientInstance.getTx.mock.calls as [string][]).map( + ([hash]) => hash, + ), + ).toStrictEqual(['beef', 'later']); + expect(built.calls).toStrictEqual([]); + }); + + it.each(['corrupt', 'full', 'write-error', 'nonce-error', 'read-error'])( + 'surfaces %s while preserving the original dispatch', + async (failure) => { + const built = await fixture(); + if (failure === 'corrupt') { + await built.infra.diskCache.setItem(ledgerKey, '{broken'); + } + if (failure === 'full') { + await built.infra.diskCache.setItem( + ledgerKey, + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [entry], + recovered: Array.from({ length: 32 }, (_, index) => ({ + ...oldOutcome, + recoveryId: `${index}:old`, + })), + }), + ); + } + const before = await built.infra.diskCache.getItem(ledgerKey); + if (failure === 'write-error') { + jest + .spyOn(built.infra.diskCache, 'setItem') + .mockRejectedValueOnce(new Error('disk failed')); + } + if (failure === 'nonce-error') { + built.clientInstance.getNextNonce.mockRejectedValueOnce( + new Error('nonce failed'), + ); } + if (failure === 'read-error') { + jest + .spyOn(built.infra.diskCache, 'getItem') + .mockRejectedValueOnce(new Error('disk read failed')); + } + await expect( + built.provider.reconcileRecoveredDispatches(), + ).rejects.toThrow(/corrupt|full|failed/u); + expect(await built.infra.diskCache.getItem(ledgerKey)).toBe(before); + expect(built.calls).toStrictEqual([]); + }, + ); + + it.each( + ['nonce', 'transaction', 'read', 'write'].flatMap((boundary) => + ['switch', 'deselect', 'reset', 'disconnect'].map((change) => [ + boundary, + change, + ]), + ), + )('fences the %s await after %s', async (boundary, change) => { + const built = await fixture(); + const held = createDeferred(); + const entered = createDeferred(); + const before = await built.infra.diskCache.getItem(ledgerKey); + const switchAfter = async (result: Result): Promise => { + entered.resolve(); + await held.promise; + return result; + }; + if (boundary === 'nonce') { + built.clientInstance.getNextNonce.mockImplementationOnce(() => + switchAfter({ code: 200, nonce: 42 }), + ); } - unsubscribe(); + if (boundary === 'transaction') { + built.clientInstance.getTx.mockImplementationOnce(() => + switchAfter(terminal), + ); + } + if (boundary === 'read') { + const get = jest + .spyOn(built.infra.diskCache, 'getItem') + .getMockImplementation() as (key: string) => Promise; + jest + .spyOn(built.infra.diskCache, 'getItem') + .mockImplementation(async (key) => + key === ledgerKey ? switchAfter(await get(key)) : get(key), + ); + } + if (boundary === 'write') { + const set = jest + .spyOn(built.infra.diskCache, 'setItem') + .getMockImplementation() as ( + key: string, + value: string, + ) => Promise; + jest + .spyOn(built.infra.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + await switchAfter(undefined); + await set(key, value); + }); + } + const pending = built.provider + .reconcileRecoveredDispatches() + .catch((error: unknown) => error); + await entered.promise; + if (change === 'switch') { + built.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000099', + ); + } + if (change === 'deselect') { + built.getUserAddressMock.mockImplementation(() => { + throw new Error('no wallet'); + }); + } + if (change === 'reset') { + built.fireReset(); + } + if (change === 'disconnect') { + await built.provider.disconnect(); + } + held.resolve(); + const failure = await pending; + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch( + /switch|session|account|disconnect/u, + ); + const after = await built.infra.diskCache.getItem(ledgerKey); + expect( + boundary === 'write' ? parseLedger(after).recovered.length : after, + ).toBe(boundary === 'write' ? 1 : before); + expect( + await built.infra.diskCache.getItem('lighterNonceLedger:testnet:99:7'), + ).toBeNull(); + expect(built.calls).toStrictEqual([]); }); - 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, - }, - ], - }, + it('serializes two provider instances and acknowledgment without losing recovered rows', async () => { + const built = await fixture(); + await built.infra.diskCache.setItem( + ledgerKey, + JSON.stringify({ + version: 4, + consumedFloor: 41, + entries: [entry], + recovered: [oldOutcome], }), + ); + const second = buildProvider({ platformDependencies: built.infra }); + second.clientInstance.getTx.mockResolvedValue(terminal); + const held = createDeferred(); + const entered = createDeferred(); + built.clientInstance.getTx.mockImplementationOnce(async () => { + entered.resolve(); + await held.promise; + return terminal; + }); + const pending = built.provider.reconcileRecoveredDispatches(); + await entered.promise; + const concurrent = second.provider.reconcileRecoveredDispatches(); + const ack = second.provider.acknowledgeRecoveredDispatch('40:old'); + held.resolve(); + await Promise.all([pending, concurrent, ack]); + const rows = await second.provider.getRecoveredDispatches(); + expect(rows).toHaveLength(1); + expect(rows[0].outcome).toBe('succeeded'); + expect(built.clientInstance.getTx).toHaveBeenCalledTimes(1); + expect(second.clientInstance.getTx).not.toHaveBeenCalled(); + }); + it('waits for an in-flight financial send and its durable post-dispatch transition', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + await built.provider.getOpenOrders(); + const send = built.clientInstance.sendTx.getMockImplementation() as ( + kind: number, + info: string, + ) => Promise; + const entered = createDeferred(); + const held = createDeferred(); + built.clientInstance.sendTx.mockImplementationOnce( + async (kind: number, info: string) => { + entered.resolve(); + await held.promise; + return send(kind, info); + }, + ); + const order = built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - expect(fillsCallback).not.toHaveBeenCalled(); - unsubscribe(); + await entered.promise; + expect( + parseLedger(await infra.diskCache.getItem(ledgerKey)).entries, + ).toHaveLength(1); + const nonceCalls = built.clientInstance.getNextNonce.mock.calls.length; + const reconcile = built.provider.reconcileRecoveredDispatches(); + await new Promise((resolve) => setImmediate(resolve)); + expect(built.clientInstance.getNextNonce).toHaveBeenCalledTimes( + nonceCalls, + ); + expect(built.clientInstance.getTx).not.toHaveBeenCalled(); + held.resolve(); + expect((await order).success).toBe(true); + expect(await reconcile).toStrictEqual([]); + expect( + parseLedger(await infra.diskCache.getItem(ledgerKey)).entries, + ).toStrictEqual([]); }); - it.each([ - ['non-finite size', { size: '1e999' }], - ['nonparticipant account', { ask_account_id: 7, bid_account_id: 8 }], - ['wrong maker role type', { is_maker_ask: 1 }], - ['null fee', { taker_fee: null }], - ['null counterparty fee', { maker_fee: null }], - ['unsafe trade id', { trade_id: Number.MAX_SAFE_INTEGER + 1 }], - ['negative market id', { market_id: -1 }], - ['negative ask id', { ask_id: -1 }], - ['unsafe bid id', { bid_id: Number.MAX_SAFE_INTEGER + 1 }], - ])('drops a WebSocket fill with %s', async (_scenario, overrides) => { - const { provider, clientInstance } = buildProvider({ - webSocketCtor: fakeStreamCtor, + it.each(['switch', 'deselect', 'reset', 'disconnect', 'round-trip'])( + 'fences %s while queued for the venue lock', + async (change) => { + const built = await fixture(); + const second = buildProvider({ platformDependencies: built.infra }); + const held = createDeferred(); + const entered = createDeferred(); + built.clientInstance.getTx.mockImplementationOnce(async () => { + entered.resolve(); + await held.promise; + return terminal; + }); + const first = built.provider.reconcileRecoveredDispatches(); + await entered.promise; + // Establish the second provider binding before its queued operation. + await second.provider.getRecoveredDispatches(); + const pending = second.provider + .reconcileRecoveredDispatches() + .catch((error: unknown) => error); + await Promise.resolve(); + await Promise.resolve(); + if (change === 'switch' || change === 'round-trip') { + second.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000099', + ); + } + if (change === 'deselect') { + second.getUserAddressMock.mockImplementation(() => { + throw new Error('no wallet'); + }); + } + if (change === 'reset') { + second.fireReset(); + } + if (change === 'disconnect') { + await second.provider.disconnect(); + } + if (change === 'round-trip') { + await second.provider.getRecoveredDispatches().catch(() => undefined); + second.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + await second.provider.getRecoveredDispatches(); + } + held.resolve(); + await first; + const failure = await pending; + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch( + /switch|session|account|disconnect/u, + ); + expect(second.clientInstance.getNextNonce).not.toHaveBeenCalled(); + expect(second.calls).toStrictEqual([]); + }, + ); + + it('captures the original session before account lookup and rejects an account switch', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, configuredAccountIndex: null, }); - clientInstance.getAccountsByL1Address.mockResolvedValue({ - code: 200, - l1Address: ACCOUNT.l1Address, - subAccounts: [ACCOUNT], + const entered = createDeferred(); + const held = createDeferred(); + built.clientInstance.getAccountsByL1Address.mockImplementationOnce( + async () => { + entered.resolve(); + await held.promise; + return { code: 200, subAccounts: [ACCOUNT] }; + }, + ); + const pending = built.provider + .reconcileRecoveredDispatches() + .catch((error: unknown) => error); + await entered.promise; + built.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000099', + ); + held.resolve(); + const failure = await pending; + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch(/switch|session|account/u); + expect(built.clientInstance.getNextNonce).not.toHaveBeenCalled(); + expect(jest.spyOn(infra.diskCache, 'setItem')).not.toHaveBeenCalled(); + expect(built.calls).toStrictEqual([]); + }); + + it('rejects a wallet deselection during the final local listing', async () => { + const built = await fixture(); + const get = jest + .spyOn(built.infra.diskCache, 'getItem') + .getMockImplementation() as (key: string) => Promise; + let ledgerReads = 0; + jest + .spyOn(built.infra.diskCache, 'getItem') + .mockImplementation(async (key) => { + const value = await get(key); + if (key === ledgerKey) { + ledgerReads += 1; + } + if (key === ledgerKey && ledgerReads === 2) { + built.getUserAddressMock.mockImplementation(() => { + throw new Error('deselected'); + }); + } + return value; + }); + await expect( + built.provider.reconcileRecoveredDispatches(), + ).rejects.toThrow(/session|account|wallet/u); + expect(parseLedger(await get(ledgerKey)).recovered).toHaveLength(1); + expect(built.calls).toStrictEqual([]); + }); + + it('keeps identical account and slot numbers isolated across networks', async () => { + const built = await fixture(); + const mainnet = buildProvider({ + platformDependencies: built.infra, + isTestnet: false, }); - StreamFakeWebSocket.instances = []; - const callback = jest.fn(); - const unsubscribe = provider.subscribeToOrderFills({ callback }); - await provider.getAccountState(); - await new Promise((resolve) => setTimeout(resolve, 0)); - const socket = - StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; - socket.open(); - await new Promise((resolve) => setTimeout(resolve, 0)); - callback.mockClear(); + const mainKey = 'lighterNonceLedger:mainnet:28:7'; + const mainBytes = JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [{ ...entry, txHash: 'main' }], + recovered: [], + }); + await built.infra.diskCache.setItem(mainKey, mainBytes); + const rows = await built.provider.reconcileRecoveredDispatches(); + expect(rows[0].recoveryId).toContain('testnet'); + expect(await built.infra.diskCache.getItem(mainKey)).toBe(mainBytes); + mainnet.clientInstance.getTx.mockResolvedValue({ + ...terminal, + hash: 'main', + status: 0, + }); + const mainRows = await mainnet.provider.reconcileRecoveredDispatches(); + expect(mainRows[0]).toMatchObject({ outcome: 'failed' }); + expect(mainRows[0].recoveryId).toContain('mainnet'); + expect((await built.provider.getRecoveredDispatches())[0].outcome).toBe( + 'succeeded', + ); + }); - socket.onmessage?.({ - data: JSON.stringify({ - type: 'update/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, - ask_account_pnl: '0', - bid_account_pnl: '0', - taker_position_size_before: '0.001', - taker_position_sign_changed: true, - maker_position_size_before: '0', - maker_position_sign_changed: true, - ...overrides, - }, - ], - }, + it('works after failed signer setup without another bridge attempt', async () => { + const built = await fixture(); + const execute = jest + .spyOn(built.bridge, 'execute') + .mockRejectedValue(new Error('signer unavailable')); + expect(await built.provider.getOpenOrders()).toStrictEqual([]); + expect(execute).toHaveBeenCalled(); + const bridgeCalls = execute.mock.calls.length; + expect(await built.provider.reconcileRecoveredDispatches()).toStrictEqual( + [expect.objectContaining({ outcome: 'succeeded' })], + ); + expect(execute).toHaveBeenCalledTimes(bridgeCalls); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it('does not advance memory reservations when persistence fails', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + await built.provider.getOpenOrders(); + await infra.diskCache.setItem( + ledgerKey, + JSON.stringify({ + version: 4, + consumedFloor: 0, + entries: [{ ...entry, expiresAt: 1 }], + recovered: [], }), + ); + built.clientInstance.getTx.mockResolvedValue(terminal); + jest + .spyOn(infra.diskCache, 'setItem') + .mockRejectedValueOnce(new Error('disk failed')); + await expect( + built.provider.reconcileRecoveredDispatches(), + ).rejects.toThrow('disk failed'); + built.clientInstance.getTx.mockResolvedValue(null); + const second = buildProvider({ platformDependencies: infra }); + second.clientInstance.getTx.mockResolvedValue(null); + expect( + await second.provider.reconcileRecoveredDispatches(), + ).toStrictEqual([]); + const result = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'limit', + price: '90000', }); - - expect(callback).not.toHaveBeenCalled(); - unsubscribe(); + expect(result.success).toBe(true); + const signed = built.calls + .filter((call) => call.function === '_signCreateOrder') + .at(-1); + expect(signed).toBeDefined(); + // The nonce is encoded by the real test bridge in the sent transaction. + const sent = ( + built.clientInstance.sendTx.mock.calls as [number, string][] + ).at(-1); + expect((JSON.parse(String(sent?.[1])) as { Nonce: number }).Nonce).toBe( + 42, + ); }); + }); + describe('strict recovery venue review', () => { + it('does not kick a pending current-slot journal after detached timers run', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const bytes = JSON.stringify({ + version: 4, + operationId: 'pending', + recordedAt: 1, + createdAt: 1, + nextAttemptId: 2, + apiKeyIndex: 7, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [12345], + txHash: 'abcd1234', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem(`lighterTpslJournal:testnet:${key}`, bytes); + await built.provider.reviewRecoveryVenue(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect( + await infra.diskCache.getItem(`lighterTpslJournal:testnet:${key}`), + ).toBe(bytes); + expect(built.clientInstance.getTx).not.toHaveBeenCalled(); + expect(built.clientInstance.getActiveOrders).toHaveBeenCalledTimes(1); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + + it.each(['auth', 'orders', 'metadata'] as const)( + 'rejects an account switch during the %s review boundary', + async (boundary) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + const entered = createDeferred(); + const release = createDeferred(); + if (boundary === 'auth') { + const execute = jest + .spyOn(built.bridge, 'execute') + .getMockImplementation(); + if (!execute) { + throw new Error('Missing signer implementation'); + } + jest + .spyOn(built.bridge, 'execute') + .mockImplementation(async (call) => { + if (call.function === '_createAuthToken') { + entered.resolve(); + await release.promise; + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; + }); + } else if (boundary === 'orders') { + built.clientInstance.getActiveOrders.mockImplementationOnce( + async () => { + entered.resolve(); + await release.promise; + return { code: 200, orders: [] }; + }, + ); + } else { + built.clientInstance.getOrderBookDetails.mockImplementationOnce( + async () => { + entered.resolve(); + await release.promise; + return { code: 200, orderBookDetails: [] }; + }, + ); + } + const pending = built.provider.reviewRecoveryVenue(); + const rejection = pending.catch((error: unknown) => error); + await entered.promise; + built.getUserAddressMock.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + release.resolve(); + expect(await rejection).toBeInstanceOf(Error); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + built.calls.some((call) => call.function === '_signChangePubKey'), + ).toBe(false); + }, + ); - it('validateClosePosition rejects a close with no open position', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ - code: 200, - accounts: [{ ...ACCOUNT, positions: [] }], + it('reviews a quarantined account using only a matching registered key and auth signing', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - const result = await provider.validateClosePosition({ symbol: 'BTC' }); - expect(result.isValid).toBe(false); - expect(result.error).toContain('No open Lighter position'); + const key = 'lighterNonceLedger:testnet:28:7'; + const bytes = JSON.stringify({ + version: 4, + consumedFloor: 43, + entries: [], + recovered: [ + { + recoveryId: '42:beef', + kind: 13, + intent: 'withdraw:1', + txHash: 'beef', + outcome: 'succeeded', + evidence: 'tx-status:2', + }, + ], + }); + await infra.diskCache.setItem(key, bytes); + const result = await built.provider.reviewRecoveryVenue(); + expect(result.status).toBe('ready'); + expect(result.providerId).toBe('lighter'); + if (result.status !== 'ready') { + throw new Error('Expected authoritative review'); + } + expect(result.positions).toHaveLength(1); + expect(result.orders).toHaveLength(1); + expect(built.calls.map((call) => call.function)).toStrictEqual([ + '_createClient', + '_createAuthToken', + ]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await infra.diskCache.getItem(key)).toBe(bytes); + }); + + it('rejects when no local registered key matches without registering one', async () => { + const built = buildProvider({ registeredKey: 'aa'.repeat(40) }); + await expect(built.provider.reviewRecoveryVenue()).rejects.toThrow( + /matching.*key/u, + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + built.calls.some((call) => call.function === '_signChangePubKey'), + ).toBe(false); }); + + it.each(['account', 'orders', 'metadata', 'auth'])( + 'surfaces strict %s failures', + async (boundary) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + if (boundary === 'account') { + built.clientInstance.getAccountByIndex.mockRejectedValue( + new Error('review account failure'), + ); + } + if (boundary === 'orders') { + built.clientInstance.getActiveOrders.mockRejectedValue( + new Error('review orders failure'), + ); + } + if (boundary === 'metadata') { + built.clientInstance.getOrderBookDetails.mockRejectedValue( + new Error('review metadata failure'), + ); + } + if (boundary === 'auth') { + jest + .spyOn(built.bridge, 'execute') + .mockRejectedValue(new Error('review auth failure')); + } + await expect(built.provider.reviewRecoveryVenue()).rejects.toThrow( + /review.*failure/u, + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + + it.each( + [ + [], + [{ ...ACCOUNT, index: 99 }], + [ACCOUNT, ACCOUNT], + [{ ...ACCOUNT, positions: undefined }], + ].map((accounts) => ({ accounts })), + )( + 'rejects unavailable or malformed account identity %j', + async ({ accounts }) => { + const built = buildProvider({ registeredKey: '9c'.repeat(40) }); + built.clientInstance.getAccountByIndex + .mockResolvedValueOnce({ code: 200, accounts: [ACCOUNT] }) + .mockResolvedValue({ code: 200, accounts }); + await expect(built.provider.reviewRecoveryVenue()).rejects.toThrow( + /account/u, + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); }); - 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], - }, + describe('selected durable protection resolution', () => { + it.each(['payload', 'pointer', 'source'] as const)( + 'recovers an interrupted terminal %s removal with exact cleanup', + async (boundary) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const sourceKey = `lighterTpslManual:testnet:${key}`; + const target = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem( + sourceKey, + JSON.stringify({ + version: 1, + settlementKey: key, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'source', + recordedAt: 1, + }), ); + const remove = jest.spyOn(infra.diskCache, 'removeItem'); + const realRemove = remove.getMockImplementation(); + if (!realRemove) { + throw new Error('Missing storage implementation'); + } + let failed = false; + remove.mockImplementation(async (storageKey) => { + const matches = { + source: storageKey === sourceKey, + pointer: storageKey === `lighterTpslJournal:testnet:${target}`, + payload: storageKey.startsWith( + `lighterTpslJournalOp:testnet:${target}:`, + ), + }[boundary]; + if (!failed && matches) { + failed = true; + throw new Error('terminal storage interrupted'); + } + return realRemove(storageKey); + }); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'source'])}`; + const first = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(failed).toBe(true); + expect(first.success).toBe(false); + const restarted = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + expect( + ( + await restarted.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + expect( + await infra.diskCache.getItem(`lighterTpslJournal:testnet:${target}`), + ).toBeNull(); + expect(await infra.diskCache.getItem(sourceKey)).toBeNull(); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers).toHaveLength(1); + }, + ); - it('a delayed getAccountState response never surfaces as the new account', async () => { - const { provider, clientInstance, getUserAddressMock } = buildProvider({ - configuredAccountIndex: null, + it.each([ + 'sourceRecoveryOperationId', + 'sourceRecoverySettlementKey', + ] as const)('rejects a half-linked journal missing %s', async (missing) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - clientInstance.getAccountsByL1Address.mockImplementation( - perAddressLookup(), + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const source = { + version: 4, + operationId: 'original', + sourceRecoveryOperationId: 'source', + sourceRecoverySettlementKey: key, + recordedAt: 1, + createdAt: 1, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'replace', + phase: 'manual', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [12345], + txHash: 'abcd1234', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }; + const bytes = JSON.stringify({ ...source, [missing]: undefined }); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([key]), ); - await provider.getAccountState(); // bind under A - let releaseResponse: (value: unknown) => void = () => undefined; - clientInstance.getAccountByIndex.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseResponse = resolve; - }), + await infra.diskCache.setItem(`lighterTpslJournal:testnet:${key}`, bytes); + await expect(built.provider.getPendingManualRecoveries()).rejects.toThrow( + /malformed/u, ); - 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'); + expect( + await infra.diskCache.getItem(`lighterTpslJournal:testnet:${key}`), + ).toBe(bytes); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); }); - 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(), + it('keeps the ready slot when registered discovery lists another matching key first', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + await built.provider.getOpenOrders(); + built.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [3, 7].map((apiKeyIndex) => ({ + accountIndex: 28, + apiKeyIndex, + publicKey: '9c'.repeat(40), + nonce: 1, + })), + }); + Object.assign(built.bridge, { + getRecoverableKeyIndices: async (): Promise => [3, 7], + }); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([key]), ); - 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); - }, + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${key}`, + JSON.stringify({ + version: 1, + settlementKey: key, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'source', + recordedAt: 1, + }), ); - 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(); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'source'])}`; + expect( + ( + await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + expect( + built.calls.filter((call) => call.function === '_createClient').at(-1) + ?.params[3], + ).toBe(7); + expect( + ( + await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '86000', + }) + ).success, + ).toBe(true); + expect( + built.calls.filter((call) => call.function === '_createClient').at(-1) + ?.params[3], + ).toBe(7); }); - 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; + it.each([1, 2])( + 'requires executed original evidence when books match status %s', + async (status) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const id = venue.seedTrigger('stop-loss', '80000'); + const key = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const bytes = JSON.stringify({ + version: 4, + operationId: 'original', + recordedAt: 1, + createdAt: 1, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [id], + txHash: 'abcd1234', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([key]), + ); + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${key}`, + bytes, + ); + built.clientInstance.getTx.mockResolvedValue({ + hash: 'abcd1234', + accountIndex: 28, + apiKeyIndex: 19, + nonce: 42, + status, + }); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + expect( + ( + await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(status === 2); + expect(built.clientInstance.sendTx.mock.calls.length > 0).toBe( + status === 2, + ); + }, + ); + + it.each([false, true])( + 'finishes interrupted settled cleanup while preserving newer=%s records', + async (newer) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const source = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const target = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'source'])}`; + await infra.diskCache.setItem( + `lighterTpslSuccessor:testnet:${source}:source`, + JSON.stringify({ + version: 1, + sourceSettlementKey: source, + sourceOperationId: 'source', + successorSettlementKey: target, + successorOperationId: 'successor', + state: 'settled', + ownedOrderIds: [], }), - ); - 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([]); - }); + ); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([target]), + ); + await infra.diskCache.setItem( + `lighterTpslJournal:testnet:${target}`, + JSON.stringify({ + version: 4, + operationId: newer ? 'newer' : 'successor', + sourceRecoveryOperationId: 'source', + sourceRecoverySettlementKey: source, + recordedAt: 1, + createdAt: 1, + nextAttemptId: 2, + apiKeyIndex: 7, + intent: 'remove', + phase: 'cancelling', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'cancel', + attemptId: 1, + nonce: 42, + outcome: 'accepted', + orderId: '9000', + txHash: 'abcd1234', + expiresAt: 1, + role: 'stale', + }, + ], + }), + ); + const sourceBytes = JSON.stringify({ + version: 1, + settlementKey: source, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'newer-source', + recordedAt: 1, + }); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([source]), + ); + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${source}`, + sourceBytes, + ); + const journalBefore = await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${target}`, + ); + expect( + await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + }), + ).toStrictEqual({ + status: 'settled', + providerId: 'lighter', + success: true, + }); + expect( + await infra.diskCache.getItem(`lighterTpslJournal:testnet:${target}`), + ).toBe(newer ? journalBefore : null); + expect( + await infra.diskCache.getItem(`lighterTpslManual:testnet:${source}`), + ).toBe(sourceBytes); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); - 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; + it.each(['manual', 'journal'] as const)( + 'blocks ordinary current-slot writes behind an old-slot prepared %s source', + async (sourceKind) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const source = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const target = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + await infra.diskCache.setItem( + sourceKind === 'manual' + ? 'lighterTpslManualIndex:testnet' + : 'lighterTpslJournalIndex:testnet', + JSON.stringify([source]), + ); + const bytes = JSON.stringify( + sourceKind === 'journal' + ? { + version: 4, + recordedAt: 1, + operationId: 'source', + createdAt: 1, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'remove', + phase: 'cancelling', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'cancel', + attemptId: 1, + nonce: 42, + outcome: 'accepted', + orderId: '9000', + txHash: 'abcd1234', + expiresAt: 1, + role: 'stale', + }, + ], + } + : { + version: 1, + settlementKey: source, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'source', + recordedAt: 1, + }, + ); + await infra.diskCache.setItem( + `lighterTpsl${sourceKind === 'manual' ? 'Manual' : 'Journal'}:testnet:${source}`, + bytes, + ); + await infra.diskCache.setItem( + `lighterTpslSuccessor:testnet:${source}:source`, + JSON.stringify({ + version: 1, + sourceSettlementKey: source, + sourceOperationId: 'source', + successorSettlementKey: target, + successorOperationId: 'successor', + state: 'prepared', + ownedOrderIds: [], + }), + ); + const result = await built.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(false); + expect(result.error).toContain( + 'Selected protection recovery is pending', + ); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + await infra.diskCache.getItem( + `lighterTpsl${sourceKind === 'manual' ? 'Manual' : 'Journal'}:testnet:${source}`, + ), + ).toBe(bytes); + const resumed = await built.provider.resolveRecoveryProtection({ + symbol: 'BTC', + stopLossPrice: '85000', + recoveryId: `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'source'])}`, + }); + expect(resumed.error).toBeUndefined(); + expect(resumed).toMatchObject({ status: 'settled', success: true }); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + }, + ); + + it.each([true, false])( + 'handles replacement=%s truthfully when no current position exists', + async (replacement) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const orderId = venue.seedTrigger('stop-loss', '80000'); + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [] }], + }); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const sourceKey = `lighterTpslManual:testnet:${settlementKey}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [String(orderId)], + operationId: 'original', + recordedAt: 1, + }); + await infra.diskCache.setItem(sourceKey, bytes); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + const result = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: replacement ? '85000' : undefined, + }); + expect(result.success).toBe(!replacement); + expect( + venue.rawTriggers.some((row) => row.orderIndex === orderId), + ).toBe(replacement); + expect(await infra.diskCache.getItem(sourceKey)).toBe( + replacement ? bytes : null, + ); + }, + ); + + it('serializes two providers selecting the same source without duplicate successor submission', async () => { + const infra = createMockInfrastructure(); + const first = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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'); + const second = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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, + const venue = setupTriggerVenue(first.clientInstance, first.bridge); + setupTriggerVenue(second.clientInstance, second.bridge); + const entered = createDeferred(); + const release = createDeferred(); + const execute = jest + .spyOn(first.bridge, 'execute') + .getMockImplementation(); + if (!execute) { + throw new Error('Missing bridge implementation'); + } + jest.spyOn(first.bridge, 'execute').mockImplementation(async (call) => { + if (call.function === '_signCreateOrder') { + entered.resolve(); + await release.promise; + } + const result = await execute?.(call); + if (!result) { + throw new Error('Missing fixture signer'); + } + return result; }); - clientInstance.getAccountsByL1Address.mockImplementation( - perAddressLookup(), + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${settlementKey}`, + JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'original', + recordedAt: 1, + }), ); - 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; - }); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + const pendingFirst = first.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', }); - const writeUnderA = provider.cancelOrder({ - orderId: '555', + await entered.promise; + const pendingSecond = second.provider.resolveRecoveryProtection({ + recoveryId, symbol: 'BTC', + stopLossPrice: '85000', }); - 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); + release.resolve(); + const results = await Promise.all([pendingFirst, pendingSecond]); + expect(results[0].success).toBe(true); expect( - calls.filter((call) => call.function === '_signCancelOrder'), - ).toHaveLength(0); - expect(clientInstance.sendTx).not.toHaveBeenCalled(); + first.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect(second.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(venue.rawTriggers).toHaveLength(1); }); - it('a configured account index without a bound wallet requests no user channels', async () => { - const { provider, getUserAddressMock } = buildProvider({ - webSocketCtor: fakeStreamCtor, - configuredAccountIndex: 28, + it('retains source and sends nothing when the wallet switches during successor signing', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - // No wallet account selected at mount time. - getUserAddressMock.mockImplementation(() => { - throw new Error('NO_ACCOUNT_SELECTED'); + setupTriggerVenue(built.clientInstance, built.bridge); + const execute = jest + .spyOn(built.bridge, 'execute') + .getMockImplementation(); + if (!execute) { + throw new Error('Missing bridge implementation'); + } + jest.spyOn(built.bridge, 'execute').mockImplementation(async (call) => { + const signed = await execute(call); + if (call.function === '_signCreateOrder') { + built.getUserAddressMock.mockReturnValue( + '0x9999999999999999999999999999999999999999', + ); + } + return signed; }); - 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, + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const sourceKey = `lighterTpslManual:testnet:${settlementKey}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'original', + recordedAt: 1, + }); + await infra.diskCache.setItem(sourceKey, bytes); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), ); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; expect( - socketsAfterMismatch - .flat() - .some((frame) => frame.includes('user_stats/')), + ( + await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, ).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(); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await infra.diskCache.getItem(sourceKey)).toBe(bytes); }); - 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, + it('reconciles a committed successor after response loss without a second create', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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' }, + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const orderId = venue.seedTrigger('stop-loss', '80000'); + venue.failResponseOnce(14); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${settlementKey}`, + JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [String(orderId)], + operationId: 'original', + recordedAt: 1, }), + ); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + const first = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', }); - // The frame itself is the first observer: it must be dropped, and - // the rebind replaces the socket for account B. - expect(accountCallback).toHaveBeenCalledWith( - expect.objectContaining({ totalBalance: '0', providerId: 'lighter' }), + expect(first.success).toBe(false); + const restarted = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const restartedVenue = setupTriggerVenue( + restarted.clientInstance, + restarted.bridge, ); - expect(accountCallback).not.toHaveBeenCalledWith( - expect.objectContaining({ totalBalance: '9999' }), + restartedVenue.rawTriggers.push(...venue.rawTriggers); + restartedVenue.rawInactive.push(...venue.rawInactive); + for (const [hash, state] of venue.landedTxs) { + restartedVenue.landedTxs.set(hash, state); + } + restartedVenue.setVenueNonce(venue.getVenueNonce()); + restartedVenue.setNextIndex(venue.getNextIndex()); + const second = await restarted.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(second.error).toBeUndefined(); + expect(second.success).toBe(true); + expect( + 'childOrderIds' in second ? second.childOrderIds : undefined, + ).toStrictEqual( + restartedVenue.rawTriggers + .filter((row) => row.triggerPrice === '85000') + .map((row) => String(row.orderIndex)), ); - const socketB = - StreamFakeWebSocket.instances[StreamFakeWebSocket.instances.length - 1]; - expect(socketB).not.toBe(socketA); - unsubscribe(); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + restarted.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect( + restartedVenue.rawTriggers.some((row) => row.orderIndex === orderId), + ).toBe(false); }); - - 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, + it.each([ + 'executed', + 'unknown', + 'already-complete', + 'cancel-restart', + 'retained-canceled', + 'retained-clear-crash', + 'retained-next-key', + 'retained-pointer-crash', + 'retained-index-crash', + 'failed', + 'expired-absent', + 'mismatch', + ] as const)( + 'reconciles a %s successor after key loss without a second create', + async (evidence) => { + const retainedFailure = + evidence === 'retained-canceled' || + evidence === 'retained-clear-crash' || + evidence === 'retained-next-key' || + evidence === 'retained-pointer-crash' || + evidence === 'retained-index-crash'; + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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; + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const orderId = venue.seedTrigger('stop-loss', '80000'); + venue.failResponseOnce(14); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${settlementKey}`, + JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [String(orderId)], + operationId: 'original', + recordedAt: 1, + }), + ); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + const first = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', }); - (bridge.execute as jest.Mock).mockImplementation( - async (call: LighterWasmCall) => { - if (call.function === '_createAuthToken' && stallNext) { - stallNext = false; - stallEntered(); - await new Promise((resolve) => { - releaseToken = resolve; - }); + expect(first.success).toBe(false); + const sourceBytes = await infra.diskCache.getItem( + `lighterTpslManual:testnet:${settlementKey}`, + ); + const lostKey = `${ACCOUNT.l1Address.toLowerCase()}:28:7:BTC`; + if (evidence === 'failed' || evidence === 'expired-absent') { + venue.rawTriggers.splice(1); + const pointer = JSON.parse( + (await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${lostKey}`, + )) ?? '{}', + ) as { operationId: string }; + const payloadKey = `lighterTpslJournalOp:testnet:${lostKey}:${pointer.operationId}`; + const payload = JSON.parse( + (await infra.diskCache.getItem(payloadKey)) ?? '{}', + ) as { attempts: { expiresAt: number; outcome: string }[] }; + for (const attempt of payload.attempts) { + attempt.expiresAt = 1; + attempt.outcome = 'unknown'; + } + await infra.diskCache.setItem(payloadKey, JSON.stringify(payload)); + } + const unrelatedId = venue.seedTrigger('take-profit', '120000'); + const unrelatedBytes = JSON.stringify( + venue.rawTriggers.find((row) => row.orderIndex === unrelatedId), + ); + const restarted = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + apiKeyIndex: 19, + }); + restarted.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex: 19, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], + }); + const restartedVenue = setupTriggerVenue( + restarted.clientInstance, + restarted.bridge, + { apiKeyIndex: 19 }, + ); + restartedVenue.rawTriggers.push(...venue.rawTriggers); + restartedVenue.rawInactive.push(...venue.rawInactive); + for (const [hash, state] of venue.landedTxs) { + restartedVenue.landedTxs.set(hash, state); + } + const readTx = + restarted.clientInstance.getTx.getMockImplementation() as + | (( + hash: string, + ) => Promise< + | import('../../../src/types/lighter-types.js').LighterTxLookupResponse + | null + >) + | undefined; + restarted.clientInstance.getTx.mockImplementation( + async (hash: string) => { + if (!venue.landedTxs.has(hash)) { + return readTx?.(hash); } - return realImplementation(call); + if (evidence === 'unknown' || evidence === 'expired-absent') { + return null; + } + return { + hash, + accountIndex: 28, + apiKeyIndex: evidence === 'mismatch' ? 3 : 7, + ...venue.landedTxs.get(hash), + ...(evidence === 'failed' ? { status: 0 } : {}), + }; }, ); - 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); + restartedVenue.setVenueNonce(venue.getVenueNonce()); + restartedVenue.setNextIndex(venue.getNextIndex()); + if (evidence === 'already-complete') { + restarted.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [{ ...ACCOUNT, positions: [] }], + }); + restartedVenue.rawTriggers.splice( + restartedVenue.rawTriggers.findIndex( + (row) => row.orderIndex === orderId, + ), + 1, + ); + } + if (evidence === 'cancel-restart' || retainedFailure) { + restartedVenue.failResponseOnce(15); + } + let second = await restarted.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }); + let resumedCreates = 0; + let failedObservation: unknown; + let renewedSuccess: boolean | undefined; + let renewedCreates: number | undefined; + let ordinarySuccess: boolean | undefined; + let intermediateSuccess: boolean | undefined; + let failedUnrelated: string | undefined; + let renewedUnrelated: string | undefined; + let renewedJournal: string | null | undefined; + let renewedIndex: string[] | undefined; + if (evidence === 'cancel-restart' || retainedFailure) { + intermediateSuccess = second.success; + const resumedSlot = + evidence === 'retained-next-key' || + evidence === 'retained-pointer-crash' || + evidence === 'retained-index-crash' + ? 23 + : 19; + const resumed = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + apiKeyIndex: resumedSlot, + }); + resumed.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex: resumedSlot, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], + }); + const resumedVenue = setupTriggerVenue( + resumed.clientInstance, + resumed.bridge, + { apiKeyIndex: resumedSlot }, + ); + resumedVenue.rawTriggers.push(...restartedVenue.rawTriggers); + if (retainedFailure) { + const retainedIndex = resumedVenue.rawTriggers.findIndex( + (row) => row.orderIndex !== unrelatedId, + ); + const [retained] = resumedVenue.rawTriggers.splice( + retainedIndex, + 1, + ); + resumedVenue.rawInactive.push({ ...retained, status: 'canceled' }); + } + + resumedVenue.rawInactive.push(...restartedVenue.rawInactive); + for (const [hash, state] of restartedVenue.landedTxs) { + resumedVenue.landedTxs.set(hash, state); + } + const resumedReadTx = + resumed.clientInstance.getTx.getMockImplementation() as + | (( + hash: string, + ) => Promise< + | import('../../../src/types/lighter-types.js').LighterTxLookupResponse + | null + >) + | undefined; + resumed.clientInstance.getTx.mockImplementation( + async (hash: string) => { + if (restartedVenue.landedTxs.has(hash)) { + return { + hash, + accountIndex: 28, + apiKeyIndex: 19, + ...restartedVenue.landedTxs.get(hash), + }; + } + return resumedReadTx?.(hash); + }, + ); + resumedVenue.setVenueNonce(restartedVenue.getVenueNonce()); + resumedVenue.setNextIndex(restartedVenue.getNextIndex()); + if ( + evidence === 'retained-clear-crash' || + evidence === 'retained-pointer-crash' + ) { + const remove = jest.spyOn(infra.diskCache, 'removeItem'); + const realRemove = remove.getMockImplementation(); + if (!realRemove) { + throw new Error('Missing storage removal'); + } + let interrupted = false; + remove.mockImplementation(async (key) => { + if ( + !interrupted && + (evidence === 'retained-clear-crash' + ? key.startsWith( + `lighterTpslJournalOp:testnet:${settlementKey}:`, + ) + : key === `lighterTpslJournal:testnet:${settlementKey}`) + ) { + interrupted = true; + throw new Error('Interrupted failed retirement'); + } + await realRemove(key); + }); + } + if (evidence === 'retained-index-crash') { + const write = jest.spyOn(infra.diskCache, 'setItem'); + const realWrite = write.getMockImplementation(); + if (!realWrite) { + throw new Error('Missing storage write'); + } + let interrupted = false; + write.mockImplementation(async (key, value) => { + if (!interrupted && key === 'lighterTpslJournalIndex:testnet') { + interrupted = true; + throw new Error('Interrupted failed index retirement'); + } + await realWrite(key, value); + }); + } + second = await resumed.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }); + if (retainedFailure) { + failedUnrelated = JSON.stringify( + resumedVenue.rawTriggers.find( + (row) => row.orderIndex === unrelatedId, + ), + ); + const failed = JSON.parse( + (await infra.diskCache.getItem( + `lighterTpslSuccessor:testnet:${settlementKey}:original`, + )) ?? '{}', + ) as { state: string; retainedReplacementGroups?: number[][] }; + failedObservation = { + success: second.success, + sends: resumed.clientInstance.sendTx.mock.calls.length, + source: await infra.diskCache.getItem( + `lighterTpslManual:testnet:${settlementKey}`, + ), + journalPresent: + (await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${settlementKey}`, + )) !== null, + state: failed.state, + groups: failed.retainedReplacementGroups, + }; + second = await resumed.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '86000', + }); + renewedUnrelated = JSON.stringify( + resumedVenue.rawTriggers.find( + (row) => row.orderIndex === unrelatedId, + ), + ); + renewedJournal = await infra.diskCache.getItem( + `lighterTpslJournal:testnet:${settlementKey}`, + ); + renewedIndex = JSON.parse( + (await infra.diskCache.getItem( + 'lighterTpslJournalIndex:testnet', + )) ?? '[]', + ) as string[]; + renewedSuccess = second.success; + renewedCreates = resumed.calls.filter( + (call) => call.function === '_signCreateOrder', + ).length; + ordinarySuccess = ( + await resumed.provider.updatePositionTPSL({ + symbol: 'BTC', + stopLossPrice: '87000', + }) + ).success; + } + resumedCreates = resumed.calls.filter( + (call) => call.function === '_signCreateOrder', + ).length; + } + expect(failedObservation).toStrictEqual( + retainedFailure + ? { + success: false, + sends: 0, + source: sourceBytes, + journalPresent: + evidence === 'retained-clear-crash' || + evidence === 'retained-pointer-crash', + state: 'failed', + groups: undefined, + } + : undefined, + ); + expect(failedUnrelated).toBe( + retainedFailure ? unrelatedBytes : undefined, + ); + expect(renewedUnrelated).toBe( + retainedFailure ? unrelatedBytes : undefined, + ); + expect(renewedJournal).toBe(retainedFailure ? null : undefined); + expect(renewedIndex).toStrictEqual(retainedFailure ? [] : undefined); + expect(renewedSuccess).toBe(retainedFailure ? true : undefined); + expect(renewedCreates).toBe(retainedFailure ? 1 : undefined); + expect(ordinarySuccess).toBe(retainedFailure ? true : undefined); expect( - framesAfter.some((frame) => frame.includes('account_all_orders/28')), - ).toBe(false); + JSON.stringify( + restartedVenue.rawTriggers.find( + (row) => row.orderIndex === unrelatedId, + ), + ), + ).toBe(unrelatedBytes); + expect(intermediateSuccess).toBe( + evidence === 'cancel-restart' || retainedFailure ? false : undefined, + ); + expect(resumedCreates).toBe(retainedFailure ? 2 : 0); expect( - socketB.sent.some((frame) => frame.includes('account_all_orders/28')), + second.success === true && + 'childOrderIds' in second && + second.childOrderIds?.length === 0, ).toBe(false); - // The deferred mint really ran exactly once through the stall. - expect(stallNext).toBe(false); - } finally { - nowSpy.mockRestore(); - } - unsubscribe(); - }); - }); + expect(second.success).toBe( + evidence !== 'unknown' && evidence !== 'mismatch', + ); + const expectedSends = { + executed: 1, + unknown: 0, + 'already-complete': 0, + 'cancel-restart': 1, + 'retained-canceled': 1, + 'retained-clear-crash': 1, + 'retained-next-key': 1, + 'retained-pointer-crash': 1, + 'retained-index-crash': 1, + failed: 2, + 'expired-absent': 2, + mismatch: 0, + }; + expect(restarted.clientInstance.sendTx).toHaveBeenCalledTimes( + expectedSends[evidence], + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateOrder', + ), + ).toHaveLength( + evidence === 'failed' || evidence === 'expired-absent' ? 1 : 0, + ); + expect( + restartedVenue.rawTriggers.some((row) => row.orderIndex === orderId), + ).toBe(evidence === 'unknown' || evidence === 'mismatch'); + }, + ); - 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.each(['unavailable', 'wrong-slot', 'terminal-failed'] as const)( + 'reconciles original slot attempts with %s evidence before a current-key successor', + async (evidence) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + setupTriggerVenue(built.clientInstance, built.bridge); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const journalKey = `lighterTpslJournal:testnet:${settlementKey}`; + const bytes = JSON.stringify({ + version: 4, + recordedAt: 5, + operationId: 'old-journal', + createdAt: 5, + nextAttemptId: 2, + apiKeyIndex: 19, + intent: 'replace', + phase: 'creating', + priorGrouping: 'independent', + priorTriggers: [], + attempts: [ + { + kind: 'create', + attemptId: 1, + nonce: 42, + outcome: 'unknown', + clientIds: [12345], + txHash: 'ffff00000001', + expiresAt: 9_999_999_999_999, + role: 'replacement', + }, + ], + }); + await infra.diskCache.setItem(journalKey, bytes); + await infra.diskCache.setItem( + 'lighterTpslJournalIndex:testnet', + JSON.stringify([settlementKey]), + ); + built.clientInstance.getTx.mockResolvedValue( + evidence === 'unavailable' + ? null + : { + hash: 'ffff00000001', + accountIndex: 28, + apiKeyIndex: evidence === 'wrong-slot' ? 7 : 19, + nonce: 42, + status: 0, + }, + ); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'old-journal'])}`; + const result = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(evidence === 'terminal-failed'); + expect(built.clientInstance.getTx).toHaveBeenCalledWith('ffff00000001'); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes( + evidence === 'terminal-failed' ? 1 : 0, + ); + expect(await infra.diskCache.getItem(journalKey)).toBe( + evidence === 'terminal-failed' ? null : bytes, + ); + }, + ); - 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', + it('does not submit a settled selected intent again after restart or lost response', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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', + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const orderId = venue.seedTrigger('stop-loss', '80000'); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${settlementKey}`, + JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [String(orderId)], + operationId: 'original', + recordedAt: 1, + }), + ); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + expect( + ( + await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + const restarted = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - expect(result.isValid).toBe(false); - expect(result.error).toContain('Invalid usdAmount'); + expect( + ( + await restarted.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(true); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(restarted.calls).toHaveLength(0); }); - }); - 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({ + it('keeps the selected source visible when the replacement fails at the venue', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const orderId = venue.seedTrigger('stop-loss', '80000'); + venue.setCreateTerminal('canceled'); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey, symbol: 'BTC', - size: '0.05', - orderType: 'limit', - price: '120000', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [String(orderId)], + operationId: 'original', + recordedAt: 1, }); - expect(result.success).toBe(true); - const orderCall = calls.find( - (call) => call.function === '_signCreateOrder', + await infra.diskCache.setItem( + `lighterTpslManual:testnet:${settlementKey}`, + bytes, + ); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + expect( + ( + await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }) + ).success, + ).toBe(false); + expect( + await infra.diskCache.getItem( + `lighterTpslManual:testnet:${settlementKey}`, + ), + ).toBe(bytes); + expect(venue.rawTriggers.some((row) => row.orderIndex === orderId)).toBe( + true, ); - // 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', + it('refuses dispatch after its source is replaced during journal persistence', async () => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), }); - 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({ + setupTriggerVenue(built.clientInstance, built.bridge); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:19:BTC`; + const sourceKey = `lighterTpslManual:testnet:${settlementKey}`; + const source = { + version: 1, + settlementKey, symbol: 'BTC', - usdAmount: '5000', - maxSlippageBps: 100, - priceAtCalculation: 100000, - }); - expect(result.success).toBe(true); - const orderCall = calls.find( - (call) => call.function === '_signCreateOrder', + reason: 'manual', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'original', + recordedAt: 1, + }; + await infra.diskCache.setItem(sourceKey, JSON.stringify(source)); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), ); - // 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({ + const realSet = jest + .spyOn(infra.diskCache, 'setItem') + .getMockImplementation(); + if (!realSet) { + throw new Error('Missing storage implementation'); + } + const newer = JSON.stringify({ ...source, operationId: 'newer' }); + jest + .spyOn(infra.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + await realSet(key, value); + if ( + key.startsWith('lighterTpslSuccessor:') && + (JSON.parse(value) as { state: string }).state === 'pending' + ) { + await realSet(sourceKey, newer); + } + }); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, 19, 'BTC', 'original'])}`; + const result = await built.provider.resolveRecoveryProtection({ + recoveryId, symbol: 'BTC', - usdAmount: '5000', - maxSlippageBps: 100, - // Fresh venue price is 100000; a 90000 snapshot is >1% away. - priceAtCalculation: 90000, + stopLossPrice: '85000', }); expect(result.success).toBe(false); - expect(result.error).toContain('slippage tolerance'); + expect(result.error).toMatch(/source.*changed/u); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(await infra.diskCache.getItem(sourceKey)).toBe(newer); }); + + it.each([7, 19])( + 'settles a selected slot%s obligation with the current registered key while preserving unrelated protection', + async (slot) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const venue = setupTriggerVenue(built.clientInstance, built.bridge); + const owned = venue.seedTrigger('stop-loss', '80000'); + const unrelated = venue.seedTrigger('take-profit', '120000'); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:${slot}:BTC`; + const key = `lighterTpslManual:testnet:${settlementKey}`; + await infra.diskCache.setItem( + key, + JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual recovery', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [String(owned)], + operationId: 'selected-operation', + recordedAt: 1, + }), + ); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + // Only slot 7 exists locally. Slot 19 represents a lost installation key. + Object.assign(built.bridge, { + getRecoverableKeyIndices: async (): Promise => [7], + }); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, slot, 'BTC', 'selected-operation'])}`; + const result = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '85000', + }); + expect(result.success).toBe(true); + expect(venue.rawTriggers.some((row) => row.orderIndex === owned)).toBe( + false, + ); + expect( + venue.rawTriggers.some((row) => row.orderIndex === unrelated), + ).toBe(true); + expect(await infra.diskCache.getItem(key)).toBeNull(); + expect( + built.calls.some((call) => call.function === '_signChangePubKey'), + ).toBe(false); + }, + ); + + it.each([7, 19])( + 'retains a slot%s obligation when its selected source identity is stale', + async (slot) => { + const infra = createMockInfrastructure(); + const built = buildProvider({ + platformDependencies: infra, + registeredKey: '9c'.repeat(40), + }); + const settlementKey = `${ACCOUNT.l1Address.toLowerCase()}:28:${slot}:BTC`; + const key = `lighterTpslManual:testnet:${settlementKey}`; + const bytes = JSON.stringify({ + version: 1, + settlementKey, + symbol: 'BTC', + reason: 'manual recovery', + priorIntent: 'replace', + priorTriggers: [], + survivingOrderIds: [], + operationId: 'newer-operation', + recordedAt: 1, + }); + await infra.diskCache.setItem(key, bytes); + await infra.diskCache.setItem( + 'lighterTpslManualIndex:testnet', + JSON.stringify([settlementKey]), + ); + const recoveryId = `lighter-protection:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28, slot, 'BTC', 'old-operation'])}`; + const result = await built.provider.resolveRecoveryProtection({ + recoveryId, + symbol: 'BTC', + stopLossPrice: '80000', + }); + expect(result.success).toBe(false); + expect(result.error).toMatch(/source|operation/u); + expect(await infra.diskCache.getItem(key)).toBe(bytes); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); }); +}); - 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', - ]); +describe('Lighter post-only placement', () => { + const intent: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'limit', + timeInForce: 'ALO', + size: '0.001', + price: '100000', + }; + it('validates and signs ALO as native limit TIF2 with resting expiry', async () => { + const built = buildProvider(); + expect(await built.provider.validateOrder(intent)).toStrictEqual({ + isValid: true, + }); + expect(built.calls).toHaveLength(0); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(true); + const create = built.calls.find( + (call) => call.function === '_signCreateOrder', + ); + expect(create?.params.slice(3, 11)).toStrictEqual([ + '100', + '1000000', + 0, + 0, + 2, + 0, + '0', + -1, + ]); + expect(built.clientInstance.getOrderBookOrders).toHaveBeenCalledTimes(3); + }); + it.each([ + { isBuy: true, price: '100001' }, + { isBuy: false, price: '99999' }, + ])('refuses crossing $isBuy before signer setup', async (side) => { + const built = buildProvider(); + const params = { ...intent, ...side }; + expect(await built.provider.validateOrder(params)).toMatchObject({ + isValid: false, + error: expect.stringContaining('cross') as string, + }); + expect(await built.provider.placeOrder(params)).toMatchObject({ + success: false, + error: expect.stringContaining('cross') as string, + }); + expect(built.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }); + it('refuses market ALO and inactive markets without signer setup', async () => { + const built = buildProvider(); + expect( + await built.provider.placeOrder({ ...intent, orderType: 'market' }), + ).toMatchObject({ + success: false, + error: expect.stringContaining('limit') as string, + }); + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, status: 'inactive' }, + ]); + expect(await built.provider.placeOrder(intent)).toMatchObject({ + success: false, + error: expect.stringContaining('active') as string, + }); + expect(built.calls).toHaveLength(0); + }); + it.each([{ size: '0.00001' }, { size: '0.001001' }, { price: '0.01' }])( + 'refuses invalid size/price/minimum %s without signer setup', + async (override) => { + const built = buildProvider(); + expect( + (await built.provider.validateOrder({ ...intent, ...override })) + .isValid, + ).toBe(false); + expect( + (await built.provider.placeOrder({ ...intent, ...override })).success, + ).toBe(false); + expect(built.calls).toHaveLength(0); + }, + ); + it('refuses a delayed or failed book before signer setup', async () => { + const built = buildProvider(); + const now = jest.spyOn(Date, 'now'); + const started = Date.now(); + built.clientInstance.getOrderBookOrders.mockImplementation(async () => { + now.mockReturnValue(started + 6000); + return { code: 200, totalBids: 0, totalAsks: 0, bids: [], asks: [] }; + }); + expect(await built.provider.placeOrder(intent)).toMatchObject({ + success: false, + error: expect.stringContaining('stale') as string, + }); + expect(built.calls).toHaveLength(0); + now.mockRestore(); + built.clientInstance.getOrderBookOrders.mockRejectedValue( + new Error('book unavailable'), + ); + expect(await built.provider.placeOrder(intent)).toMatchObject({ + success: false, + error: 'book unavailable', }); - - 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', + }); + it('fences an account switch during book preflight', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBookOrders.mockImplementation(async () => { + built.getUserAddressMock.mockReturnValue('0xother'); + return { code: 200, totalBids: 0, totalAsks: 0, bids: [], asks: [] }; + }); + expect((await built.provider.placeOrder(intent)).success).toBe(false); + expect(built.calls).toHaveLength(0); + }); + it('rechecks crossing before signing after setup', async () => { + const built = buildProvider(); + built.clientInstance.getOrderBookOrders + .mockResolvedValueOnce({ + code: 200, + totalBids: 0, + totalAsks: 0, + bids: [], + asks: [], + }) + .mockResolvedValue({ + code: 200, + totalBids: 0, + totalAsks: 1, + bids: [], + asks: [ + { + orderId: '2', + orderIndex: 2, + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '100000', + orderExpiry: 0, + transactionTime: 1, + }, + ], }); - expect(history[1]).toMatchObject({ - type: 'deposit', - amount: '10000.000000', - status: 'completed', + expect(await built.provider.placeOrder(intent)).toMatchObject({ + success: false, + error: expect.stringContaining('cross') as string, + }); + expect( + built.calls.some((call) => call.function === '_signCreateOrder'), + ).toBe(false); + }); + it.each(['disconnect', 'account switch'] as const)( + 'fences %s during ALO signing before send', + async (change) => { + const built = buildProvider(); + const execute = jest + .spyOn(built.bridge, 'execute') + .getMockImplementation(); + if (!execute) { + throw new Error('Missing bridge implementation'); + } + jest.spyOn(built.bridge, 'execute').mockImplementation(async (call) => { + const result = await execute(call); + if (call.function === '_signCreateOrder') { + if (change === 'disconnect') { + await built.provider.disconnect(); + } else { + built.getUserAddressMock.mockReturnValue('0xother'); + } + } + return result; }); + expect((await built.provider.placeOrder(intent)).success).toBe(false); + expect( + built.clientInstance.sendTx.mock.calls.filter(([kind]) => kind === 14), + ).toHaveLength(0); + }, + ); + it('snapshots caller intent before the asynchronous book read', async () => { + const built = buildProvider(); + const params = { ...intent }; + built.clientInstance.getOrderBookOrders.mockImplementation(async () => { + params.isBuy = false; + params.price = '90000'; + params.size = '0.1'; + return { code: 200, totalBids: 0, totalAsks: 0, bids: [], asks: [] }; + }); + expect((await built.provider.placeOrder(params)).success).toBe(true); + expect( + built.calls + .find((call) => call.function === '_signCreateOrder') + ?.params.slice(3, 8), + ).toStrictEqual(['100', '1000000', 0, 0, 2]); + }); + it.each([ + { chaseIntervalMs: 1000 }, + { clientOrderId: '123' }, + { takeProfitSize: '0.001' }, + ])('refuses unsupported ALO intent %s before setup', async (field) => { + const built = buildProvider(); + expect( + (await built.provider.validateOrder({ ...intent, ...field })).isValid, + ).toBe(false); + expect( + (await built.provider.placeOrder({ ...intent, ...field })).success, + ).toBe(false); + expect(built.calls).toHaveLength(0); + }); + it('preserves the exact client handle and quarantines response loss across restart', async () => { + const deps = createMockInfrastructure(); + const disk = new Map(); + jest + .spyOn(deps.diskCache, 'getItem') + .mockImplementation(async (key: string) => disk.get(key) ?? null); + jest + .spyOn(deps.diskCache, 'setItem') + .mockImplementation(async (key: string, value: string) => { + disk.set(key, value); + }); + const built = buildProvider({ platformDependencies: deps }); + built.clientInstance.sendTx.mockImplementation(async (kind: number) => { + if (kind === LIGHTER_TX_TYPE_CREATE_ORDER) { + throw new Error('response lost'); + } + return { code: 200, txHash: 'registered' }; }); - - 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'); + const result = await built.provider.placeOrder(intent); + expect(result).toMatchObject({ + success: false, + orderId: expect.any(String) as string, + providerId: 'lighter', + error: expect.stringContaining('response lost') as string, }); + expect([...disk.values()].join(' ')).toContain( + `placeOrder:BTC:${result.orderId}`, + ); + const restarted = buildProvider({ platformDependencies: deps }); + expect((await restarted.provider.placeOrder(intent)).success).toBe(false); + expect( + restarted.calls.some((call) => call.function === '_signCreateOrder'), + ).toBe(false); + }); +}); - it('rejects unknown transfer types through the public ledger path', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getTransferHistory.mockResolvedValue({ +describe('Lighter bounded Chase provider probe', () => { + type ExactLookup = LighterClientService['getOrdersByClientIds']; + type TxLookup = LighterClientService['getTx']; + const intent: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'chase', + size: '0.0002', + leverage: 1, + chaseIntervalMs: 1000, + chaseMaxDurationMs: 60000, + chaseMaxRepricings: 1, + chaseMaxDistanceBps: 100, + }; + const setup = ( + options: Parameters[0] = {}, + ): BuiltProvider => { + const built = buildProvider({ + ...options, + chaseTestnetProbe: true, + nativeTwapTestnetProbe: true, + registeredKey: '9c'.repeat(40), + }); + const expiry = Date.now() + 100000; + built.clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [ + { + ...ACCOUNT.positions[0], + position: '0', + positionValue: '0', + initialMarginFraction: '100', + }, + ], + }, + ], + }); + built.clientInstance.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [], + }); + built.clientInstance.getOrderBookOrders.mockResolvedValue({ + code: 200, + totalBids: 1, + totalAsks: 1, + bids: [ + { + orderIndex: 10, + orderId: '10', + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '99999.8', + orderExpiry: 0, + transactionTime: 1, + }, + ], + asks: [ + { + orderIndex: 11, + orderId: '11', + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '100001', + orderExpiry: 0, + transactionTime: 1, + }, + ], + }); + built.clientInstance.getTrades.mockResolvedValue({ code: 200, trades: [] }); + built.clientInstance.getTx.mockImplementation(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: Number( + [...built.calls] + .reverse() + .find((call) => call.function === '_signCancelOrder')?.params[3], + ), + status: 2, + })); + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (_account: number, _token: string, ids: string[]) => { + const creates = built.calls.filter( + (call) => call.function === '_signCreateOrder', + ); + const childIndex = creates.findIndex( + (call) => String(call.params[2]) === ids[0], + ); + const orderId = String(9001 + childIndex); + const create = built.calls.find( + (call) => + call.function === '_signCreateOrder' && + String(call.params[2]) === ids[0], + ); + if (!create) { + return { code: 200, orders: [] }; + } + const canceled = built.calls.some( + (call) => + call.function === '_signCancelOrder' && + String(call.params[2]) === orderId, + ); + return { + code: 200, + orders: [ + { + orderIndex: Number(orderId), + orderId, + clientOrderIndex: Number(ids[0]), + clientOrderId: ids[0], + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.0002', + remainingBaseAmount: '0.0002', + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: String(Number(create.params[4]) / 10), + isAsk: false, + type: 'limit', + timeInForce: 'post-only', + reduceOnly: 0, + status: canceled ? 'canceled' : 'open', + orderExpiry: expiry, + timestamp: 1, + nonce: 2075941, + }, + ], + }; + }, + ); + return built; + }; + it.each(['chase', 'twap'] as const)( + 'excludes a second native probe across strategies after %s dispatch', + async (first) => { + jest.useFakeTimers(); + const built = setup(); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ code: 200, - transfers: [ + orderBookDetails: [ { - id: 'bad-transfer', - assetId: 3, - amount: '100.000000', - fee: '0.000000', - timestamp: 1700000004000, - type: 'L2TransferRebate', - fromL1Address: ACCOUNT.l1Address, - toL1Address: ACCOUNT.l1Address, - fromAccountIndex: 28, - toAccountIndex: 999, - txHash: '0xbad', + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, }, ], }); - - await expect(provider.getUserNonFundingLedgerUpdates()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - }); - - it('rethrows venue integrity failures from every financial-history API', async () => { - const integrityError = new Error( - 'Invalid Lighter venue data: malformed financial history', - ); - - const funding = buildProvider(); - funding.clientInstance.getPositionFundings.mockRejectedValue( - integrityError, - ); - await expect(funding.provider.getFunding()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - - const ledger = buildProvider(); - ledger.clientInstance.getDepositHistory.mockRejectedValue(integrityError); - await expect( - ledger.provider.getUserNonFundingLedgerUpdates(), - ).rejects.toThrow('Invalid Lighter venue data'); - - const history = buildProvider(); - history.clientInstance.getWithdrawHistory.mockRejectedValue( - integrityError, - ); - await expect(history.provider.getUserHistory()).rejects.toThrow( - 'Invalid Lighter venue data', - ); - }); - - it('honors fill limit, time range, symbol, and selected-account contracts', async () => { - const { provider, clientInstance } = buildProvider(); - const matchingTrade = { - tradeId: 1, - txHash: '0xtrade', - type: 'trade', - marketId: 1, - size: '0.1', - price: '90000', - usdAmount: '9000', - askId: 10, - bidId: 11, - askAccountId: 99, - bidAccountId: 28, - isMakerAsk: false, - timestamp: 1700000000000, - askAccountPnl: '0', - bidAccountPnl: '0', - takerPositionSizeBefore: '0', - makerPositionSizeBefore: '0', - takerPositionSignChanged: true, - makerPositionSignChanged: true, + const twap: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'twap', + size: '0.00020', + leverage: 1, + twapDuration: 1, }; - clientInstance.getTrades - .mockResolvedValueOnce({ - code: 200, - nextCursor: 'page-2', - trades: [{ ...matchingTrade, tradeId: 2, timestamp: 1700000001000 }], - }) - .mockResolvedValue({ code: 200, trades: [matchingTrade] }); - - expect( - await provider.getOrderFills({ - startTime: 1699999999999, - endTime: 1700000000001, - limit: 7, - }), - ).toHaveLength(1); - expect(clientInstance.getTrades).toHaveBeenCalledWith( - 28, - expect.any(String), - { limit: 100, cursor: 'page-2', marketId: undefined }, - ); + try { + await built.provider.placeOrder(first === 'chase' ? intent : twap); + const creates = built.calls.filter( + (call) => call.function === '_signCreateOrder', + ).length; + expect(creates).toBe(1); + const result = await built.provider.placeOrder( + first === 'chase' ? twap : intent, + ); + expect(result.success).toBe(false); + expect(result.error).toContain('native probe remains unresolved'); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(creates); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each([ + { usdAmount: '19' }, + { usdAmount: '21' }, + { usdAmount: '0' }, + { usdAmount: 'NaN' }, + { usdAmount: '20', size: '0.0001' }, + { usdAmount: '20', currentPrice: 200000 }, + { usdAmount: '20', priceAtCalculation: 200000 }, + ])( + 'rejects contradictory Mobile Chase sizing %j before setup', + async (override) => { + const built = setup(); expect( - await provider.getOrderFills({ startTime: 1700000000001 }), - ).toStrictEqual([]); - expect(await provider.getOrFetchFills({ symbol: 'ETH' })).toStrictEqual( - [], - ); - - await expect( - provider.getOrderFills({ - user: '0x0000000000000000000000000000000000000001', - }), - ).rejects.toThrow('selected wallet'); - await expect( - provider.getOrderFills({ aggregateByTime: true }), - ).rejects.toThrow('aggregateByTime'); - }); - - it('continues fill pagination across pages sharing the start-time boundary', async () => { - const { provider, clientInstance } = buildProvider(); - const boundaryTrade = { - tradeId: 1, - txHash: '0xtrade', - type: 'trade', - marketId: 1, - size: '0.1', - price: '90000', - usdAmount: '9000', - askId: 10, - bidId: 11, - askAccountId: 99, - bidAccountId: 28, - isMakerAsk: false, - timestamp: 1700000000000, - askAccountPnl: '0', - bidAccountPnl: '0', - takerPositionSizeBefore: '0', - makerPositionSizeBefore: '0', - takerPositionSignChanged: true, - makerPositionSignChanged: true, - }; - clientInstance.getTrades - .mockResolvedValueOnce({ - code: 200, - nextCursor: 'same-timestamp', - trades: [boundaryTrade], - }) - .mockResolvedValueOnce({ - code: 200, - trades: [{ ...boundaryTrade, tradeId: 2 }], - }); - - const fills = await provider.getOrderFills({ - startTime: boundaryTrade.timestamp, + (await built.provider.placeOrder({ ...intent, ...override })).success, + ).toBe(false); + expect(built.calls).toHaveLength(0); + }, + ); + it('floors the Mobile Chase size while retaining its explicit USD budget', async () => { + jest.useFakeTimers(); + const built = setup(); + try { + const result = await built.provider.placeOrder({ + ...intent, + size: '0.000209', + usdAmount: '20', + currentPrice: 100000, }); - expect(fills.map((fill) => fill.orderId)).toStrictEqual(['11', '11']); - expect(clientInstance.getTrades).toHaveBeenCalledTimes(2); - }); - - it('surfaces fill-history transport and authentication failures', async () => { - const { provider, clientInstance } = buildProvider(); - clientInstance.getTrades.mockRejectedValue(new Error('auth expired')); - await expect(provider.getOrderFills()).rejects.toThrow('auth expired'); - await expect(provider.getOrFetchFills()).rejects.toThrow('auth expired'); - }); - - it('suppresses deposits until the Lighter bridge call is implemented; withdrawals remain mainnet-only', () => { - // 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([]); - // A caller hint must never override the provider's venue network: - // DepositService currently asks for mainnet routes, and advertising - // one here would fund mainnet while trading remains on testnet. - expect(provider.getDepositRoutes({ isTestnet: false })).toStrictEqual([]); - expect(provider.getWithdrawalRoutes({ isTestnet: false })).toStrictEqual( - [], + expect(result.success).toBe(true); + expect(result.submittedSize).toBe('0.0002'); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + it('leaves Chase unadvertised without the probe or an injected signer', async () => { + const disabled = buildProvider(); + const readOnly = setup({ withBridge: false }); + expect( + await disabled.provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ supportedStrategies: ['scale'] }); + expect( + await readOnly.provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ supportedStrategies: ['scale'] }); + }); + it('accepts the Mobile Chase USD request and advertises the enabled testnet probe', async () => { + jest.useFakeTimers(); + const built = setup(); + try { + const result = await built.provider.placeOrder({ + ...intent, + usdAmount: '20', + currentPrice: 100000, + priceAtCalculation: 100000, + }); + expect(result.success).toBe(true); + expect(result.submittedSize).toBe('0.0002'); + expect( + await built.provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ supportedStrategies: ['scale', 'chase'] }); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + it('advertises only the enabled testnet Chase probe and rejects mainnet probe construction', async () => { + expect(() => + buildProvider({ chaseTestnetProbe: true, isTestnet: false }), + ).toThrow('testnet-only'); + const built = setup(); + expect( + await built.provider.getOrderCapabilities({ symbol: 'BTC' }), + ).toMatchObject({ supportedStrategies: ['scale', 'chase'] }); + expect((await buildProvider().provider.placeOrder(intent)).success).toBe( + false, + ); + }); + it('rejects over-budget, leverage and unbounded options before signer setup', async () => { + const built = setup(); + for (const override of [ + { size: '0.001' }, + { leverage: 2 }, + { chaseMaxRepricings: 21 }, + { chaseMaxDurationMs: 301000 }, + ]) { + expect( + (await built.provider.placeOrder({ ...intent, ...override })).success, + ).toBe(false); + } + expect(built.calls).toHaveLength(0); + }); + it('refuses a mismatched provider route before any signer setup', async () => { + const built = setup(); + expect( + ( + await built.provider.placeOrder({ + ...intent, + providerId: 'hyperliquid', + }) + ).success, + ).toBe(false); + expect(built.calls).toHaveLength(0); + }); + it('serializes one native cancel/reprice and then terminates at its count bound', async () => { + jest.useFakeTimers(); + const built = setup(); + try { + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (_account: number, _token: string, ids: string[]) => { + const creates = built.calls.filter( + (call) => call.function === '_signCreateOrder', + ); + const index = creates.findIndex( + (call) => String(call.params[2]) === ids[0], + ); + const create = creates[index]; + if (!create) { + return { code: 200, orders: [] }; + } + const orderId = String(9001 + index); + const canceled = built.calls.some( + (call) => + call.function === '_signCancelOrder' && + String(call.params[2]) === orderId, + ); + return { + code: 200, + orders: [ + { + orderIndex: Number(orderId), + orderId, + clientOrderIndex: Number(ids[0]), + clientOrderId: ids[0], + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.0002', + remainingBaseAmount: '0.0002', + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: String(Number(create.params[4]) / 10), + isAsk: false, + type: 'limit', + timeInForce: 'post-only', + reduceOnly: 0, + status: canceled ? 'canceled' : 'open', + orderExpiry: 9999999999999, + timestamp: 1, + nonce: 2075941, + }, + ], + }; + }, ); - - const { provider: mainnetProvider } = buildProvider({ - isTestnet: false, + const first = await built.provider.placeOrder(intent); + expect(first.success).toBe(true); + built.clientInstance.getOrderBookOrders.mockResolvedValue({ + code: 200, + totalBids: 1, + totalAsks: 1, + bids: [ + { + orderIndex: 10, + orderId: '10', + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '99999.9', + orderExpiry: 0, + transactionTime: 1, + }, + ], + asks: [ + { + orderIndex: 11, + orderId: '11', + ownerAccountIndex: 99, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price: '100001', + orderExpiry: 0, + transactionTime: 1, + }, + ], }); - expect(mainnetProvider.getDepositRoutes()).toStrictEqual([]); - const [mainnetRoute] = mainnetProvider.getWithdrawalRoutes(); - expect(mainnetRoute.chainId).toBe('eip155:1'); - expect(mainnetRoute.contractAddress).toBe( - '0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7', + await jest.advanceTimersByTimeAsync(1000); + expect( + built.calls + .filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ) + .map((call) => call.function), + ).toStrictEqual([ + '_signCreateOrder', + '_signCancelOrder', + '_signCreateOrder', + ]); + expect((await built.provider.getChaseOrders())[0].repricings).toBe(1); + await jest.advanceTimersByTimeAsync(1000); + expect((await built.provider.getChaseOrders())[0].status).toBe( + 'canceled', ); - expect(mainnetRoute.assetId).toContain( - 'erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + expect((await built.provider.getNativeChaseRecords())[0].stopReason).toBe( + 'repricing_limit_reached', ); - expect( - mainnetProvider.getDepositRoutes({ isTestnet: true }), - ).toStrictEqual([]); - }); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + it('places an exact owned native ALO child and exposes its public management handle', async () => { + const built = setup(); + const result = await built.provider.placeOrder(intent); + if (!result.success) { + throw new Error(result.error); + } + expect(result.success).toBe(true); + expect(result.providerId).toBe('lighter'); + expect(result.orderId).toMatch(/^lighter-chase:/u); + expect(result.childOrderIds).toStrictEqual(['9001']); + expect( + built.calls + .find((call) => call.function === '_signCreateOrder') + ?.params.slice(6, 8), + ).toStrictEqual([0, 2]); + const orders = await built.provider.getChaseOrders(); + expect(orders[0].handle).toBe(result.orderId); + expect(orders[0].status).toBe('active'); + await built.provider.disconnect(); + }); + it('suspends in-flight work and never repeats placement after disconnect', async () => { + const built = setup(); + const result = await built.provider.placeOrder(intent); + if (!result.success) { + throw new Error(result.error); + } + expect(result.success).toBe(true); + await built.provider.suspendChaseOrders(); + const creates = built.calls.filter( + (call) => call.function === '_signCreateOrder', + ).length; + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'canceled', + restingOrderId: null, + }); + expect((await built.provider.getNativeChaseRecords())[0].stopReason).toBe( + 'backgrounded', + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(creates); + await built.provider.disconnect(); }); + const diskDependencies = (): ReturnType => { + const deps = createMockInfrastructure(); + const disk = new Map(); + jest + .spyOn(deps.diskCache, 'getItem') + .mockImplementation(async (key: string) => disk.get(key) ?? null); + jest + .spyOn(deps.diskCache, 'setItem') + .mockImplementation(async (key: string, value: string) => { + disk.set(key, value); + }); + return deps; + }; + describe('public Chase ownership', () => { + const journalKey = `lighterChase:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28])}`; + const accountsKey = `lighterRecoveryAccounts:testnet:${ACCOUNT.l1Address.toLowerCase()}`; + const route = (handle: string): GetChaseOrderOwnershipParams => ({ + handle, + providerId: 'lighter' as const, + }); + const stored = async ( + deps: ReturnType, + ): Promise<{ version: 1; records: LighterChaseRecord[] }> => { + const raw = await deps.diskCache.getItem(journalKey); + if (!raw) { + throw new Error('Missing test journal'); + } + return JSON.parse(raw) as { version: 1; records: LighterChaseRecord[] }; + }; + type BoundarySnapshot = { + wasm: LighterWasmCall[]; + bridge: number[]; + client: number[]; + writes: number; + removes: number; + timers: number; + }; + const boundaries = ( + built: BuiltProvider, + deps: ReturnType, + ): BoundarySnapshot => ({ + wasm: [...built.calls], + bridge: Object.values(built.bridge) + .filter((value) => jest.isMockFunction(value)) + .map((mock) => mock.mock.calls.length), + client: Object.values(built.clientInstance) + .filter((value) => jest.isMockFunction(value)) + .map((mock) => mock.mock.calls.length), + writes: jest.spyOn(deps.diskCache, 'setItem').mock.calls.length, + removes: jest.spyOn(deps.diskCache, 'removeItem').mock.calls.length, + timers: jest.getTimerCount(), + }); + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('retains every exact child after multiple reprices and current-child replacement through the public messenger', async () => { + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + const { controller, host } = ownershipController(built.provider); + try { + await controller.init(); + const book = + (await built.clientInstance.getOrderBookOrders()) as Awaited< + ReturnType + >; + built.clientInstance.getOrderBookOrders.mockResolvedValue({ + ...book, + bids: [{ ...book.bids[0], price: '99999.6' }], + }); + const placed = await built.provider.placeOrder({ + ...intent, + chaseMaxRepricings: 3, + }); + expect(placed.success).toBe(true); + const handle = placed.orderId as string; + for (const price of ['99999.7', '99999.8']) { + built.clientInstance.getOrderBookOrders.mockResolvedValue({ + ...book, + bids: [{ ...book.bids[0], price }], + }); + await jest.advanceTimersByTimeAsync(1000); + } + const journal = await stored(deps); + expect(journal.records[0].children).toHaveLength(3); + const before = boundaries(built, deps); - 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); + const snapshot = await host.messenger.call( + 'PerpsController:getChaseOrderOwnership', + route(handle), + ); + + expect(snapshot.status).toBe('available'); + if (snapshot.status !== 'available') { + throw new Error('Missing ownership'); + } + expect(snapshot.owner).toStrictEqual({ + providerId: 'lighter', + walletAddress: ACCOUNT.l1Address.toLowerCase(), + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 7, + }); + expect(snapshot.handle).toBe(handle); + expect(snapshot.order).toMatchObject({ + handle, + restingOrderId: '9003', + repricings: 2, + }); + expect( + snapshot.children.map((child) => child.clientOrderId), + ).toStrictEqual( + journal.records[0].children.map((child) => child.clientOrderId), + ); + expect( + snapshot.children.map((child) => child.observation), + ).toStrictEqual( + journal.records[0].children.map((child) => child.observation), + ); + expect( + snapshot.children.map((child) => child.observation?.orderId), + ).toStrictEqual(['9001', '9002', '9003']); + expect( + snapshot.children.map((child) => child.observation?.terminal), + ).toStrictEqual([true, true, false]); + expect(boundaries(built, deps)).toStrictEqual(before); + expect(keyringCalls(host.call)).toStrictEqual([]); + expect(await stored(deps)).toStrictEqual(journal); + expect( + await controller.getChaseOrderOwnership({ + ...route(handle), + owner: snapshot.owner, + }), + ).toStrictEqual(snapshot); + } finally { + await built.provider.disconnect(); } - // 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('preserves response loss and unknown child ownership after a signerless absent-authority restart', async () => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), ); - }); - - 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, + const placed = await original.provider.placeOrder(intent); + expect(placed.success).toBe(false); + expect(placed.orderId).toStrictEqual(expect.any(String)); + await original.provider.disconnect(); + const restarted = buildProvider({ + platformDependencies: deps, + withBridge: false, + apiKeyIndex: 8, + configuredAccountIndex: null, }); - expect(await provider.validateWithdrawal({} as never)).toMatchObject({ - isValid: false, + restarted.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + subAccounts: [], }); - }); + const { controller, host } = ownershipController(restarted.provider); + try { + const journal = await stored(deps); + const before = boundaries(restarted, deps); - it('derives estimates instead of returning false zeros', async () => { - const { provider } = buildProvider(); - await expect( - provider.calculateMaintenanceMargin({ asset: 'UNKNOWN' }), - ).rejects.toThrow('maintenance margin metadata unavailable'); - // The liquidation preview uses the ISOLATED formula when the caller - // requests (or omits) that supported mode, 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', + const snapshot = await controller.getChaseOrderOwnership( + route(placed.orderId as string), + ); + + expect(snapshot).toMatchObject({ + status: 'available', + owner: { apiKeyIndex: 7, accountIndex: 28 }, + order: { status: 'termination_pending', restingOrderId: null }, + children: [{ placement: { phase: 'attempted' } }], + }); + if (snapshot.status !== 'available') { + throw new Error('Missing ownership'); + } + expect(snapshot.children[0].observation).toBeUndefined(); + expect(snapshot.children[0].clientOrderId).toBe( + journal.records[0].children[0].clientOrderId, + ); + const publicPlacement = { ...journal.records[0].children[0].placement }; + Reflect.deleteProperty(publicPlacement, 'acknowledged'); + expect(snapshot.children[0].placement).toStrictEqual(publicPlacement); + expect(boundaries(restarted, deps)).toStrictEqual(before); + expect(keyringCalls(host.call)).toStrictEqual([]); + expect(await stored(deps)).toStrictEqual(journal); + await jest.advanceTimersByTimeAsync(60000); + expect(boundaries(restarted, deps)).toStrictEqual({ + ...before, + timers: 0, + }); + expect( + await controller.getChaseOrderOwnership({ + ...route(placed.orderId as string), + owner: { ...snapshot.owner, apiKeyIndex: 8 }, }), - ), - ).toBeCloseTo(108.6957, 3); - await expect( - provider.calculateLiquidationPrice({ - entryPrice: 100, - leverage: 10, - direction: 'long', - asset: 'UNKNOWN', - }), - ).rejects.toThrow('maintenance margin metadata unavailable'); - // Malformed inputs report the explicit zero contract. - expect( - await provider.calculateLiquidationPrice({ - entryPrice: 0, - leverage: 10, - direction: 'long', - }), - ).toBe('0.00'); - await expect( - provider.calculateLiquidationPrice({ - entryPrice: 100, - leverage: 10, - direction: 'long', - marginType: 'cross', - asset: 'BTC', - }), - ).rejects.toThrow('cross-margin liquidation previews'); - expect(await provider.getMaxLeverage('BTC')).toBeGreaterThan(0); - await expect(provider.getMaxLeverage('UNKNOWN')).rejects.toThrow( - 'margin metadata unavailable', - ); - // 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)); - expect(await provider.previewPositionModify({} as never)).toStrictEqual({ - status: 'unsupported', - reason: 'provider', + ).toMatchObject({ status: 'unavailable', reason: 'owner_mismatch' }); + } finally { + await restarted.provider.disconnect(); + } + }); + + it('preserves exact observed quantities and excludes private journal extensions', async () => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + const journal = await stored(deps); + const child = journal.records[0].children[0]; + journal.records[0].error = 'secret-error-auth'; + child.observation = { + orderId: '9001', + terminal: false, + filledSize: '0.000010', + filledNotional: '0.9999900', + remainingSize: '0.000190', + }; + Object.assign(child, { + privateKey: 'secret-private-key', + authToken: 'secret-auth', + signedPayload: 'secret-payload', + }); + Object.assign(child.placement, { + txInfo: 'secret-transaction', + signature: 'secret-signature', + }); + await deps.diskCache.setItem(journalKey, JSON.stringify(journal)); + const restarted = buildProvider({ + platformDependencies: deps, + withBridge: false, + }); + const { controller } = ownershipController(restarted.provider); + const before = boundaries(restarted, deps); + try { + const snapshot = await controller.getChaseOrderOwnership( + route(placed.orderId as string), + ); + expect(snapshot.status).toBe('available'); + if (snapshot.status !== 'available') { + throw new Error('Missing ownership'); + } + expect(snapshot.children[0].observation).toStrictEqual( + child.observation, + ); + expect(snapshot.executedSize).toBe('0.00001'); + expect(snapshot.executedNotional).toBe('0.99999'); + expect(JSON.stringify(snapshot)).not.toContain('secret-'); + expect(boundaries(restarted, deps)).toStrictEqual(before); + expect(await stored(deps)).toStrictEqual(journal); + } finally { + await restarted.provider.disconnect(); + } + }); + + it.each(['wallet', 'network', 'account', 'key', 'provider'] as const)( + 'fences a mismatched expected %s owner without acquiring cleanup authority', + async (field) => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + const restarted = buildProvider({ + platformDependencies: deps, + apiKeyIndex: 8, + }); + const { controller } = ownershipController(restarted.provider); + try { + const snapshot = await controller.getChaseOrderOwnership( + route(placed.orderId as string), + ); + if (snapshot.status !== 'available') { + throw new Error('Missing ownership'); + } + const owner = { ...snapshot.owner }; + if (field === 'wallet') { + owner.walletAddress = '0xforeign'; + } else if (field === 'network') { + owner.network = 'mainnet'; + } else if (field === 'account') { + owner.accountIndex = 29; + } else if (field === 'key') { + owner.apiKeyIndex = 8; + } else { + owner.providerId = 'hyperliquid'; + } + const before = boundaries(restarted, deps); + expect( + await controller.getChaseOrderOwnership({ + ...route(placed.orderId as string), + owner, + }), + ).toMatchObject({ status: 'unavailable', reason: 'owner_mismatch' }); + expect(boundaries(restarted, deps)).toStrictEqual(before); + const canceled = await restarted.provider.cancelOrder({ + orderId: snapshot.handle, + symbol: 'BTC', + orderType: 'chase', + providerId: 'lighter', + }); + expect(canceled.success).toBe(false); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(0); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await restarted.provider.disconnect(); + } + }, + ); + + it.each(['prepared', 'signed', 'acknowledged', 'failed'] as const)( + 'observes %s durable children without retiring or replaying them', + async (phase) => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + const journal = await stored(deps); + const child = journal.records[0].children[0]; + child.placement.phase = phase; + delete child.placement.acknowledged; + delete child.observation; + await deps.diskCache.setItem(journalKey, JSON.stringify(journal)); + const restarted = buildProvider({ + platformDependencies: deps, + withBridge: false, + }); + const { controller } = ownershipController(restarted.provider); + const before = boundaries(restarted, deps); + try { + const snapshot = await controller.getChaseOrderOwnership( + route(placed.orderId as string), + ); + expect(snapshot).toMatchObject({ + status: 'available', + children: [{ placement: { phase } }], + }); + expect(boundaries(restarted, deps)).toStrictEqual(before); + expect(await stored(deps)).toStrictEqual(journal); + } finally { + await restarted.provider.disconnect(); + } + }, + ); + + it.each([ + 'foreign handle', + 'missing journal', + 'empty journal', + 'missing account index', + 'foreign wallet', + 'foreign network', + ] as const)( + 'returns unavailable for %s without claiming an empty complete inventory', + async (condition) => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + if (condition === 'missing journal') { + const read = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + if (!read) { + throw new Error('Missing test read'); + } + jest + .spyOn(deps.diskCache, 'getItem') + .mockImplementation(async (key) => + key === journalKey ? null : await read(key), + ); + } else if (condition === 'empty journal') { + await deps.diskCache.setItem( + journalKey, + JSON.stringify({ version: 1, records: [] }), + ); + } else if (condition === 'missing account index') { + await deps.diskCache.setItem(accountsKey, '[]'); + } + const restarted = buildProvider({ + platformDependencies: deps, + withBridge: false, + isTestnet: condition !== 'foreign network', + }); + const { controller, host } = ownershipController(restarted.provider); + if (condition === 'foreign wallet') { + restarted.getUserAddressMock.mockReturnValue('0xother'); + host.selectAccount('0xother'); + } else if (condition === 'foreign network') { + controller.changeContext({ isTestnet: false }); + } + try { + const before = boundaries(restarted, deps); + const snapshot = await controller.getChaseOrderOwnership( + route( + condition === 'foreign handle' + ? 'foreign:opaque' + : (placed.orderId as string), + ), + ); + expect(snapshot).toMatchObject({ + status: 'unavailable', + reason: 'not_found', + }); + expect(snapshot).not.toHaveProperty('children'); + expect(boundaries(restarted, deps)).toStrictEqual(before); + } finally { + await restarted.provider.disconnect(); + } + }, + ); + + it.each([ + 'invalid JSON', + 'dangling child', + 'corrupt account index', + 'foreign journal owner', + 'ambiguous handle', + ] as const)( + 'rejects %s through the public controller without side effects', + async (condition) => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + const journal = await stored(deps); + if (condition === 'invalid JSON') { + await deps.diskCache.setItem(journalKey, '{broken'); + } else if (condition === 'corrupt account index') { + await deps.diskCache.setItem(accountsKey, '[28,28]'); + } else { + if (condition === 'dangling child') { + journal.records[0].children[0].clientOrderId = 'not-an-id'; + } else if (condition === 'foreign journal owner') { + journal.records[0].intent.owner.wallet = '0xforeign'; + } else { + await deps.diskCache.setItem(accountsKey, '[28,29]'); + const duplicate = structuredClone(journal); + duplicate.records[0].intent.owner.accountIndex = 29; + await deps.diskCache.setItem( + `lighterChase:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 29])}`, + JSON.stringify(duplicate), + ); + } + await deps.diskCache.setItem(journalKey, JSON.stringify(journal)); + } + const restarted = buildProvider({ + platformDependencies: deps, + withBridge: false, + }); + const { controller } = ownershipController(restarted.provider); + const before = boundaries(restarted, deps); + try { + await expect( + controller.getChaseOrderOwnership(route(placed.orderId as string)), + ).rejects.toThrow( + { + 'invalid JSON': /JSON/u, + 'dangling child': /persisted child identity/u, + 'corrupt account index': /account index is corrupt/u, + 'foreign journal owner': /persisted ownership/u, + 'ambiguous handle': /ambiguous durable ownership/u, + }[condition], + ); + expect(boundaries(restarted, deps)).toStrictEqual(before); + } finally { + await restarted.provider.disconnect(); + } + }, + ); + + it.each(['account', 'network', 'provider', 'instance'] as const)( + 'rejects %s changes at controller readiness before calling the provider', + async (condition) => { + const deps = diskDependencies(); + const built = buildProvider({ + platformDependencies: deps, + withBridge: false, + }); + const { controller, host } = ownershipController(built.provider); + const inspect = jest.spyOn(built.provider, 'getChaseOrderOwnership'); + const before = boundaries(built, deps); + const result = controller.getChaseOrderOwnership( + route('opaque-handle'), + ); + if (condition === 'account') { + host.selectAccount('0xother'); + } else if (condition === 'network') { + controller.changeContext({ isTestnet: false }); + } else if (condition === 'provider') { + controller.changeContext({ activeProvider: 'hyperliquid' }); + } else { + controller.activate(buildProvider().provider); + } + try { + await expect(result).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(inspect).not.toHaveBeenCalled(); + expect(boundaries(built, deps)).toStrictEqual(before); + } finally { + await built.provider.disconnect(); + } + }, + ); + + it('refuses foreign provider routes before any storage or authority work', async () => { + const deps = diskDependencies(); + const built = buildProvider({ + platformDependencies: deps, + withBridge: false, }); + const { controller } = ownershipController(built.provider); + const before = boundaries(built, deps); + const reads = jest.spyOn(deps.diskCache, 'getItem').mock.calls.length; + try { + await expect( + controller.getChaseOrderOwnership({ + handle: 'opaque', + providerId: 'hyperliquid', + }), + ).rejects.toThrow('provider does not match'); + await expect( + built.provider.getChaseOrderOwnership({ + handle: 'opaque', + providerId: 'hyperliquid', + }), + ).rejects.toThrow('matching provider'); + expect(jest.spyOn(deps.diskCache, 'getItem').mock.calls).toHaveLength( + reads, + ); + expect(boundaries(built, deps)).toStrictEqual(before); + } finally { + await built.provider.disconnect(); + } }); - 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(); + it.each([ + 'account', + 'account round trip', + 'network', + 'provider', + 'instance', + 'signer reset', + 'disconnect', + ] as const)( + 'fences %s changes while durable storage awaits', + async (condition) => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + const restarted = buildProvider({ platformDependencies: deps }); + const { controller, host } = ownershipController(restarted.provider); + const entered = createDeferred(); + const pending = createDeferred(); + const read = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + if (!read) { + throw new Error('Missing storage read'); + } + jest + .spyOn(deps.diskCache, 'getItem') + .mockImplementation(async (key) => { + if (key === journalKey) { + entered.resolve(); + await pending.promise; + } + return await read(key); + }); + const before = boundaries(restarted, deps); + const result = controller.getChaseOrderOwnership( + route(placed.orderId as string), + ); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + await entered.promise; + if (condition === 'account') { + host.selectAccount('0xother'); + restarted.getUserAddressMock.mockReturnValue('0xother'); + } else if (condition === 'account round trip') { + host.selectAccount('0xother'); + restarted.getUserAddressMock.mockReturnValue('0xother'); + host.rootMessenger.publish( + 'AccountsController:selectedAccountChange', + host.rootMessenger.call('AccountsController:getSelectedAccount'), + ); + host.selectAccount(ACCOUNT.l1Address as `0x${string}`); + restarted.getUserAddressMock.mockReturnValue(ACCOUNT.l1Address); + host.rootMessenger.publish( + 'AccountsController:selectedAccountChange', + host.rootMessenger.call('AccountsController:getSelectedAccount'), + ); + } else if (condition === 'network') { + controller.changeContext({ isTestnet: false }); + } else if (condition === 'provider') { + controller.changeContext({ activeProvider: 'hyperliquid' }); + } else if (condition === 'instance') { + controller.activate(buildProvider().provider); + } else if (condition === 'signer reset') { + restarted.fireReset(); + } else { + await controller.disconnect(); + } + const afterChange = boundaries(restarted, deps); + pending.resolve(); + try { + expect(await settled).toBeInstanceOf(Error); + expect(boundaries(restarted, deps)).toStrictEqual(afterChange); + expect(afterChange.writes).toBe(before.writes); + expect(keyringCalls(host.call)).toStrictEqual([]); + } finally { + pending.resolve(); + await restarted.provider.disconnect(); + } + }, + ); + + it('rejects an aggregate route replaced during a real Lighter storage read', async () => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + await original.provider.disconnect(); + const restarted = buildProvider({ + platformDependencies: deps, + withBridge: false, + }); + const providers = new Map([ + ['lighter', restarted.provider], + ]); + const aggregate = new AggregatedPerpsProvider({ + providers, + defaultProvider: 'lighter', + infrastructure: deps, + }); + const { controller } = ownershipController(aggregate, 'aggregated'); + const entered = createDeferred(); + const pending = createDeferred(); + const read = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + if (!read) { + throw new Error('Missing test read'); + } + jest.spyOn(deps.diskCache, 'getItem').mockImplementation(async (key) => { + if (key === journalKey) { + entered.resolve(); + await pending.promise; + } + return await read(key); + }); + const before = boundaries(restarted, deps); + const result = controller.getChaseOrderOwnership( + route(placed.orderId as string), + ); + const settled = result.then( + () => undefined, + (error: unknown) => error, + ); + await entered.promise; + const replacement = buildProvider({ withBridge: false }); + const replacementRead = jest.spyOn( + replacement.provider, + 'getChaseOrderOwnership', + ); + providers.set('lighter', replacement.provider); + pending.resolve(); + try { + expect(await settled).toStrictEqual( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + ); + expect(replacementRead).not.toHaveBeenCalled(); + expect(boundaries(restarted, deps)).toStrictEqual(before); + } finally { + pending.resolve(); + await restarted.provider.disconnect(); + await aggregate.disconnect(); } - expect(() => provider.setLiveDataConfig({})).not.toThrow(); }); - it('returns an explorer URL', () => { - const { provider } = buildProvider(); - expect(provider.getBlockExplorerUrl('0xabc')).toContain('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/address/0xabc'); - expect(provider.getBlockExplorerUrl()).toMatch(/^https:/u); + it('routes the public aggregate read only to Lighter and reports HyperLiquid unsupported without reading Chase sessions', async () => { + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + const placed = await built.provider.placeOrder(intent); + const hyperliquid = new HyperLiquidProvider({ + platformDependencies: createMockInfrastructure(), + isTestnet: true, + messenger: createKeyringlessMessenger().messenger, + }); + const legacyRead = jest.spyOn(hyperliquid, 'getChaseOrders'); + const aggregate = new AggregatedPerpsProvider({ + providers: new Map([ + ['lighter', built.provider], + ['hyperliquid', hyperliquid], + ]), + defaultProvider: 'hyperliquid', + infrastructure: deps, + }); + const { controller } = ownershipController(aggregate, 'aggregated'); + try { + const before = boundaries(built, deps); + expect( + await controller.getChaseOrderOwnership( + route(placed.orderId as string), + ), + ).toMatchObject({ status: 'available', providerId: 'lighter' }); + expect( + await controller.getChaseOrderOwnership({ + providerId: 'hyperliquid', + handle: 'opaque-hl-handle', + }), + ).toMatchObject({ status: 'unsupported', providerId: 'hyperliquid' }); + expect(legacyRead).not.toHaveBeenCalled(); + expect(boundaries(built, deps)).toStrictEqual(before); + await deps.diskCache.setItem(journalKey, '{broken'); + const afterCorruption = boundaries(built, deps); + await expect( + controller.getChaseOrderOwnership(route(placed.orderId as string)), + ).rejects.toThrow(/JSON/u); + expect(boundaries(built, deps)).toStrictEqual(afterCorruption); + } finally { + await aggregate.disconnect(); + } }); }); - 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); + const cancelParams = ( + orderId: string, + ): { + symbol: string; + orderId: string; + orderType: 'chase'; + providerId: 'lighter'; + } => ({ symbol: 'BTC', orderId, orderType: 'chase', providerId: 'lighter' }); + describe('bounded Chase provider settlement', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it.each(['live', 'missing'] as const)( + 'reports success after delayed exact %s cancellation visibility', + async (visibility) => { + const built = setup(); + try { + const placed = await built.provider.placeOrder({ + ...intent, + chaseIntervalMs: 120000, + chaseMaxDurationMs: 180000, + }); + const original = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!original) { + throw new Error('Missing exact lookup'); + } + let observedAt: number | undefined; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + const result = await original(...args); + if ( + built.calls.some((call) => call.function === '_signCancelOrder') + ) { + observedAt ??= Date.now() + 2000; + if (Date.now() < observedAt) { + return { + ...result, + orders: + visibility === 'missing' + ? [] + : result.orders.map((row) => ({ + ...row, + status: 'open', + })), + }; + } + } + return result; + }, + ); + const canceling = built.provider.cancelOrder( + cancelParams(String(placed.orderId)), + ); + await jest.advanceTimersByTimeAsync(2500); + expect(await canceling).toMatchObject({ success: true }); + const record = (await built.provider.getNativeChaseRecords())[0]; + expect(record).toMatchObject({ + status: 'canceled', + executedSize: '0', + executedNotional: '0', + }); + expect(record.children).toHaveLength(1); + expect(record.children[0].cancellations).toHaveLength(1); + expect(record.children[0].cancellations[0].nonce).toBe( + Number(record.children[0].placement.nonce) + 1, + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect( + await built.provider.cancelOrder( + cancelParams(String(placed.orderId)), + ), + ).toMatchObject({ success: true }); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + } finally { + await built.provider.disconnect(); + } + }, + ); + + it('preserves foreign cancellation transaction errors without treating them as indexer lag', async () => { + const built = setup(); + try { + const placed = await built.provider.placeOrder(intent); + const tx = built.clientInstance.getTx.getMockImplementation() as + | TxLookup + | undefined; + if (!tx) { + throw new Error('Missing transaction lookup'); + } + built.clientInstance.getTx.mockImplementation(async (hash: string) => ({ + ...(await tx(hash)), + accountIndex: 99, + })); + expect( + await built.provider.cancelOrder( + cancelParams(String(placed.orderId)), + ), + ).toMatchObject({ + success: false, + error: 'Lighter Chase transaction identity mismatch', + }); + const reads = + built.clientInstance.getOrdersByClientIds.mock.calls.length; + await jest.advanceTimersByTimeAsync(10000); + expect(built.clientInstance.getOrdersByClientIds).toHaveBeenCalledTimes( + reads, + ); + expect(built.clientInstance.sendTx).toHaveBeenCalledTimes(2); + expect( + (await built.provider.getNativeChaseRecords())[0].children[0] + .cancellations, + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + } }); + }); - it('rejects malformed version-4 recovered-dispatch rows instead of dropping them', async () => { - const infra = createMockInfrastructure(); - await infra.diskCache.setItem( - 'lighterNonceLedger:testnet:28:7', - JSON.stringify({ - version: 4, - consumedFloor: 0, - entries: [], - recovered: [ - { - recoveryId: '42:beef', - kind: 13, - intent: 'withdraw:25', - txHash: 'beef', - outcome: 'succeeded', - // Missing evidence makes the v4 safety document malformed. - }, - ], - }), + describe('late Chase quote preparation', () => { + type Book = Awaited>; + const finalBook = async ( + built: BuiltProvider, + change: (book: Book) => Book, + ): Promise => { + const book = (await built.clientInstance.getOrderBookOrders(1)) as Book; + let reads = 0; + built.clientInstance.getOrderBookOrders.mockImplementation(async () => { + reads += 1; + return reads >= 4 ? change(structuredClone(book)) : book; + }); + }; + it('places and cancels one exact child after cumulative slow preparation with a fresh final quote', async () => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + const start = Date.now(); + let preparing = false; + let reads = 0; + const advance = (ms: number): void => { + jest.setSystemTime(Date.now() + ms); + }; + const book = (await built.clientInstance.getOrderBookOrders(1)) as Book; + built.clientInstance.getOrderBookOrders.mockImplementation(async () => { + reads += 1; + if (reads === 3) { + preparing = true; + } + if (preparing) { + advance(50); + } + return book; + }); + const delayed = + ( + original: (...args: Args) => Promise, + ms: number, + ): ((...args: Args) => Promise) => + async (...args) => { + if (preparing) { + advance(ms); + } + return await original(...args); + }; + const markets = + built.clientInstance.getOrderBooks.getMockImplementation(); + const account = + built.clientInstance.getAccountByIndex.getMockImplementation(); + const active = + built.clientInstance.getActiveOrders.getMockImplementation(); + const nonce = built.clientInstance.getNextNonce.getMockImplementation(); + const read = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + const write = jest + .spyOn(deps.diskCache, 'setItem') + .getMockImplementation(); + const execute = jest + .spyOn(built.bridge, 'execute') + .getMockImplementation(); + if ( + !markets || + !account || + !active || + !nonce || + !read || + !write || + !execute + ) { + throw new Error('Missing Chase latency collaborators'); + } + built.clientInstance.getOrderBooks.mockImplementation( + delayed(markets, 1100), ); - const built = buildProvider({ platformDependencies: infra }); - - await expect(built.provider.getRecoveredDispatches()).rejects.toThrow( - 'corrupt', + built.clientInstance.getAccountByIndex.mockImplementation( + delayed(account, 700), ); + built.clientInstance.getActiveOrders.mockImplementation( + delayed(active, 400), + ); + built.clientInstance.getNextNonce.mockImplementation(delayed(nonce, 200)); + jest.spyOn(deps.diskCache, 'getItem').mockImplementation(async (key) => { + if (preparing && key.startsWith('lighterNonceLedger:')) { + advance(5); + } + return await read(key); + }); + jest + .spyOn(deps.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + if (preparing) { + advance(key.startsWith('lighterNonceLedger:') ? 200 : 100); + } + return await write(key, value); + }); + jest.spyOn(built.bridge, 'execute').mockImplementation(async (call) => { + if (call.function === '_signCreateOrder') { + advance(400); + } + return await execute(call); + }); + try { + const result = await built.provider.placeOrder(intent); + expect(Date.now() - start).toBeGreaterThan(5000); + expect({ + result, + signingCalls: built.calls.filter( + (call) => call.function === '_signCreateOrder', + ).length, + transportCalls: built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ).length, + }).toMatchObject({ + result: { success: true, childOrderIds: ['9001'] }, + signingCalls: 1, + transportCalls: 1, + }); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ), + ).toHaveLength(1); + const record = (await built.provider.getNativeChaseRecords())[0]; + expect( + record.children[0].quotedAt - record.intent.startedAt, + ).toBeGreaterThan(3000); + preparing = false; + built.clientInstance.getOrderBookOrders.mockRejectedValue( + new Error('price feed unavailable'), + ); + expect( + ( + await built.provider.cancelOrder( + cancelParams(result.orderId as string), + ) + ).success, + ).toBe(true); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject( + { + status: 'canceled', + children: [{ observation: { terminal: true } }], + }, + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } }); - - it.each([ - ['succeeded', 'abab000000000042'], - ['unknown', null], - ] as const)( - 'preserves a blocking %s outcome when 32 non-blocking failures already fill the ledger', - async (expectedOutcome, txHash) => { - const infra = createMockInfrastructure(); - const failedOutcomes = Array.from({ length: 32 }, (_, index) => ({ - recoveryId: `${String(index)}:failed`, - kind: 13, - intent: `withdraw:${String(index)}`, - txHash: `dead${String(index).padStart(8, '0')}`, - outcome: 'failed', - evidence: 'tx-status:0', - })); - await infra.diskCache.setItem( - 'lighterNonceLedger:testnet:28:7', - JSON.stringify({ - version: 4, - consumedFloor: 0, - entries: [ - { - nonce: 42, - txHash, - expiresAt: 9_999_999_999_999, - kind: 13, - intent: 'withdraw:25', - owner: null, + it('signs and persists the genuine final price rather than relabeling the early quote', async () => { + jest.useFakeTimers(); + const built = setup(); + await finalBook(built, (book) => ({ + ...book, + bids: book.bids.map((row) => ({ ...row, price: '99999.6' })), + })); + try { + expect((await built.provider.placeOrder(intent)).success).toBe(true); + const create = built.calls.filter( + (call) => call.function === '_signCreateOrder', + ); + expect(create).toHaveLength(1); + expect(create[0].params[4]).toBe('999997'); + expect( + (await built.provider.getNativeChaseRecords())[0].children[0].price, + ).toBe('99999.7'); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ), + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + it.each(['distance', 'notional', 'incomplete same-side book'] as const)( + 'refuses a final quote violating %s before signing or transport', + async (condition) => { + jest.useFakeTimers(); + const built = setup(); + await finalBook(built, (book) => + condition === 'incomplete same-side book' + ? { ...book, totalBids: 2, bids: [] } + : { + ...book, + bids: book.bids.map((row) => ({ + ...row, + price: condition === 'distance' ? '103000' : '100000', + })), + asks: book.asks.map((row) => ({ ...row, price: '104000' })), }, - ], - recovered: failedOutcomes, - }), ); - const built = buildProvider({ - registeredKey: '9c'.repeat(40), - platformDependencies: infra, + try { + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect(result.error).toContain( + { + distance: 'maximum distance', + notional: 'aggregate budget', + 'incomplete same-side book': 'book is incomplete', + }[condition], + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each(['missing', 'malformed', 'crossed', 'stale', 'backwards'] as const)( + 'refuses a %s final book before signing or transport', + async (condition) => { + jest.useFakeTimers(); + const built = setup(); + await finalBook(built, (book) => { + if (condition === 'stale' || condition === 'backwards') { + jest.setSystemTime( + Date.now() + (condition === 'stale' ? 5001 : -1), + ); + return book; + } + if (condition === 'missing') { + return { ...book, totalBids: 0, bids: [] }; + } + return { + ...book, + bids: book.bids.map((row) => ({ + ...row, + price: condition === 'malformed' ? 'NaN' : '100002', + })), + }; }); - setupTriggerVenue(built.clientInstance, built.bridge); - if (expectedOutcome === 'succeeded') { - built.clientInstance.getTx.mockResolvedValue({ - code: 200, - hash: txHash, - accountIndex: 28, - apiKeyIndex: 7, - nonce: 42, - status: 2, + try { + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each(['account', 'signer reset'] as const)( + 'refuses %s changes during final quote acquisition', + async (condition) => { + jest.useFakeTimers(); + const built = setup(); + await finalBook(built, (book) => { + if (condition === 'account') { + built.getUserAddressMock.mockReturnValue('0xother'); + } else { + built.fireReset(); + } + return book; + }); + try { + expect((await built.provider.placeOrder(intent)).success).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each(['final exposure', 'nonce append', 'attempted journal'] as const)( + 'retains stale-quote refusal after slow %s and retires only the proven-unsent nonce', + async (condition) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + let signed = false; + let delayed = false; + const execute = jest + .spyOn(built.bridge, 'execute') + .getMockImplementation(); + const write = jest + .spyOn(deps.diskCache, 'setItem') + .getMockImplementation(); + const account = + built.clientInstance.getAccountByIndex.getMockImplementation() as + | LighterClientService['getAccountByIndex'] + | undefined; + if (!execute || !write || !account) { + throw new Error('Missing dispatch collaborators'); + } + jest.spyOn(built.bridge, 'execute').mockImplementation(async (call) => { + const result = await execute(call); + if (call.function === '_signCreateOrder') { + signed = true; + } + return result; + }); + built.clientInstance.getAccountByIndex.mockImplementation( + async ( + ...args: Parameters + ) => { + if (signed && !delayed && condition === 'final exposure') { + delayed = true; + jest.setSystemTime(Date.now() + 5001); + } + return await account(...args); + }, + ); + jest + .spyOn(deps.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + if ( + signed && + !delayed && + ((condition === 'nonce append' && + key.startsWith('lighterNonceLedger:') && + !value.includes('"entries":[]')) || + (condition === 'attempted journal' && + key.startsWith('lighterChase:') && + value.includes('"phase":"attempted"'))) + ) { + delayed = true; + jest.setSystemTime(Date.now() + 5001); + } + return await write(key, value); }); - } else { - built.clientInstance.getNextNonce.mockResolvedValue({ - code: 200, - nonce: 43, + try { + const result = await built.provider.placeOrder(intent); + expect(result).toMatchObject({ + success: false, + error: 'Lighter Chase quote is stale before dispatch', + }); + expect(delayed).toBe(true); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + const child = (await built.provider.getNativeChaseRecords())[0] + .children[0]; + expect(child.placement).toMatchObject({ + phase: 'failed', + nonce: expect.any(Number) as number, + txHash: expect.any(String) as string, }); + const ledger = await deps.diskCache.getItem( + 'lighterNonceLedger:testnet:28:7', + ); + expect(ledger).toContain('"entries":[]'); + expect( + built.clientInstance.getNextNonce.mock.results.at(-1)?.value, + ).toBeDefined(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); } - - const firstRetry = await built.provider.withdraw({ amount: '25' }); - expect(firstRetry.success).toBe(false); - const recovered = await built.provider.getRecoveredDispatches(); - expect(recovered).toHaveLength(32); - expect(recovered.some((row) => row.outcome === expectedOutcome)).toBe( - true, - ); - // The blocking row must survive the disk round-trip, not merely the - // in-memory resolve pass. - const secondRetry = await built.provider.withdraw({ amount: '25' }); - expect(secondRetry.success).toBe(false); - expect(secondRetry.error).toContain( - expectedOutcome === 'succeeded' - ? 'actually completed' - : 'UNKNOWN outcome', - ); }, ); + it('retains the frozen parent refusal when signing alone ages a fresh quote', async () => { + jest.useFakeTimers(); + const built = setup(); + const execute = jest + .spyOn(built.bridge, 'execute') + .getMockImplementation(); + if (!execute) { + throw new Error('Missing signer implementation'); + } + jest.spyOn(built.bridge, 'execute').mockImplementation(async (call) => { + if (call.function === '_signCreateOrder') { + jest.setSystemTime(Date.now() + 5001); + } + return await execute(call); + }); + try { + const result = await built.provider.placeOrder(intent); + expect(result).toMatchObject({ + success: false, + error: 'Lighter Chase quote is stale before dispatch', + }); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + }); + it.each(['preparation', 'readiness', 'account exclusion'] as const)( + 'rejects a Chase start suspended during deferred %s before service registration', + async (boundary) => { + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + const entered = createDeferred(); + const pending = createDeferred(); + if (boundary === 'preparation') { + const original = + built.clientInstance.getOrderBookOrders.getMockImplementation() as + | LighterClientService['getOrderBookOrders'] + | undefined; + if (!original) { + throw new Error('Missing book implementation'); + } + built.clientInstance.getOrderBookOrders.mockImplementationOnce( + async ( + ...args: Parameters + ) => { + entered.resolve(); + await pending.promise; + return await original(...args); + }, + ); + } else if (boundary === 'readiness') { + const original = jest + .spyOn(built.bridge, 'createClient') + .getMockImplementation(); + if (!original) { + throw new Error('Missing signer readiness implementation'); + } + jest + .spyOn(built.bridge, 'createClient') + .mockImplementationOnce(async (params) => { + entered.resolve(); + await pending.promise; + return await original(params); + }); + } else { + const original = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + if (!original) { + throw new Error('Missing disk read implementation'); + } + jest + .spyOn(deps.diskCache, 'getItem') + .mockImplementation(async (key) => { + if (key.startsWith('lighterNativeTwap:')) { + entered.resolve(); + await pending.promise; + } + return await original(key); + }); + } + try { + const starting = built.provider.placeOrder(intent); + await entered.promise; + expect(await built.provider.suspendChaseOrders()).toStrictEqual([]); + pending.resolve(); - it('keeps an exact-hash pending transaction unresolved until it reaches a terminal status', async () => { - const registeredKey = '9c'.repeat(40); - const infra = createMockInfrastructure(); - 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, - }, - ], - recovered: [], - }), + const result = await starting; + + expect(result).toMatchObject({ + success: false, + error: expect.stringContaining('interrupted') as string, + }); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect(await built.provider.getChaseOrders()).toStrictEqual([]); + expect((await built.provider.placeOrder(intent)).success).toBe(true); + } finally { + pending.resolve(); + await built.provider.disconnect(); + } + }, + ); + it('keeps a wrong-symbol Chase cancellation under its original duration owner', async () => { + jest.useFakeTimers(); + const built = setup(); + try { + const result = await built.provider.placeOrder({ + ...intent, + chaseIntervalMs: 120000, + chaseMaxDurationMs: 180000, + }); + if (!result.orderId) { + throw new Error('Missing Chase handle'); + } + + const refusal = await built.provider.cancelOrder({ + ...cancelParams(result.orderId), + symbol: 'ETH', + }); + + expect(refusal.success).toBe(false); + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'active', + restingOrderId: '9001', + }); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + await jest.advanceTimersByTimeAsync(179999); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + await jest.advanceTimersByTimeAsync(1); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject({ + status: 'canceled', + stopReason: 'duration_reached', + }); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + const restartAbsentChase = async ( + state: 'empty' | 'retained' | 'other-wallet' | 'other-network', + ): Promise => { + const deps = diskDependencies(); + if (state !== 'empty') { + const original = setup({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + const result = await original.provider.placeOrder(intent); + if (!result.success) { + throw new Error(result.error); + } + await original.provider.disconnect(); + } + const restarted = buildProvider({ + platformDependencies: deps, + configuredAccountIndex: null, + isTestnet: state !== 'other-network', + }); + if (state === 'other-wallet') { + restarted.getUserAddressMock.mockReturnValue('0xother'); + } + restarted.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [], + }); + return restarted; + }; + it('preserves remembered Chase ownership after absent-account restart: retained', async () => { + const restarted = await restartAbsentChase('retained'); + try { + const orders = await restarted.provider.getChaseOrders(); + const suspended = await restarted.provider.suspendChaseOrders(); + + expect(orders).toHaveLength(1); + expect(orders[0]).toMatchObject({ + status: 'termination_pending', + restingOrderId: '9001', + }); + expect(suspended).toHaveLength(1); + expect(suspended[0]).toMatchObject({ + handle: orders[0].handle, + status: 'termination_pending', + restingOrderId: '9001', + }); + const cancellation = await restarted.provider.cancelOrder( + cancelParams(orders[0].handle), ); - const built = buildProvider({ - registeredKey, - platformDependencies: infra, + expect(cancellation).toMatchObject({ + success: false, + error: expect.any(String) as string, }); - setupTriggerVenue(built.clientInstance, built.bridge); - let txStatus = 1; - built.clientInstance.getTx.mockImplementation(async () => ({ + expect( + (await restarted.provider.getNativeChaseRecords())[0].stopReason, + ).toBe('backgrounded'); + expect(restarted.calls).toHaveLength(0); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(restarted.clientInstance.getApiKeys).not.toHaveBeenCalled(); + } finally { + await restarted.provider.disconnect(); + } + }); + const absentChase = ( + deps: ReturnType, + ): BuiltProvider => { + const built = buildProvider({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [], + }); + return built; + }; + const bindRetainedChaseLookup = ( + built: BuiltProvider, + record: LighterChaseRecord, + terminal: () => boolean = () => + built.calls.some( + (call) => + call.function === '_signCancelOrder' && + String(call.params[2]) === record.children[0].observation?.orderId, + ), + ): void => { + const child = record.children[0]; + const orderExpiry = Date.now() + 100000; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (_account: number, _token: string, ids: string[]) => ({ code: 200, - hash: 'abab000000000042', + orders: ids.includes(child.clientOrderId) + ? [ + { + orderIndex: Number(child.observation?.orderId), + orderId: child.observation?.orderId ?? '', + clientOrderIndex: Number(child.clientOrderId), + clientOrderId: child.clientOrderId, + marketIndex: record.intent.marketId, + ownerAccountIndex: record.intent.owner.accountIndex, + initialBaseAmount: child.size, + remainingBaseAmount: child.size, + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: child.price, + isAsk: !record.intent.isBuy, + type: 'limit', + timeInForce: 'post-only', + reduceOnly: 0, + status: terminal() ? 'canceled' : 'open', + orderExpiry, + timestamp: 1, + nonce: 2075941, + }, + ] + : [], + }), + ); + }; + describe('public Chase cancellation reconciliation without dispatch', () => { + const journalKey = `lighterChase:${JSON.stringify(['testnet', ACCOUNT.l1Address.toLowerCase(), 28])}`; + const settled = async ( + pending: Promise, + ): Promise => { + const outcome = pending.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + await jest.advanceTimersByTimeAsync( + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS + 1, + ); + const result = await outcome; + if ('error' in result) { + throw result.error; + } + return result.value; + }; + const retainedCancellation = async ( + acknowledged = true, + ): Promise<{ + deps: ReturnType; + original: BuiltProvider; + restored: BuiltProvider; + retained: LighterChaseRecord; + params: ReconcileChaseOrderCancellationParams; + }> => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder({ + ...intent, + chaseIntervalMs: 120000, + chaseMaxDurationMs: 180000, + }); + if (!placed.success || !placed.orderId) { + throw new Error(placed.error ?? 'Missing placement'); + } + const initial = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, initial, () => false); + if (!acknowledged) { + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('cancel reply lost'), + ); + } + expect( + ( + await settled( + original.provider.cancelOrder(cancelParams(placed.orderId)), + ) + ).success, + ).toBe(false); + const retained = (await original.provider.getNativeChaseRecords())[0]; + const child = retained.children[0]; + const cancellation = child.cancellations[0]; + if ( + cancellation.nonce === undefined || + !cancellation.txHash || + !cancellation.expiresAt + ) { + throw new Error('Missing original cancellation identity'); + } + const params: ReconcileChaseOrderCancellationParams = { + providerId: 'lighter', + handle: placed.orderId, + owner: { + providerId: 'lighter', + walletAddress: retained.intent.owner.wallet, + network: retained.intent.owner.network, + accountIndex: retained.intent.owner.accountIndex, + apiKeyIndex: retained.intent.owner.apiKeyIndex, + }, + clientOrderId: child.clientOrderId, + cancellation: { + nonce: cancellation.nonce, + txHash: cancellation.txHash, + expiresAt: cancellation.expiresAt, + }, + }; + await original.provider.disconnect(); + const restored = setup({ platformDependencies: deps }); + bindRetainedChaseLookup(restored, retained, () => false); + restored.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: cancellation.txHash, accountIndex: 28, apiKeyIndex: 7, - nonce: 42, - status: txStatus, - })); - - const pendingRetry = await built.provider.withdraw({ amount: '25' }); - expect(pendingRetry.success).toBe(false); - expect(pendingRetry.error).toContain('unresolved outcome'); - expect(await built.provider.getRecoveredDispatches()).toStrictEqual([]); - const pendingDoc = JSON.parse( - (await infra.diskCache.getItem( - 'lighterNonceLedger:testnet:28:7', - )) as string, - ) as { - consumedFloor: number; - entries: unknown[]; - recovered: unknown[]; - }; - expect(pendingDoc.consumedFloor).toBe(0); - expect(pendingDoc.entries).toHaveLength(1); - expect(pendingDoc.recovered).toHaveLength(0); + nonce: cancellation.nonce, + status: 2, + }); + return { deps, original, restored, retained, params }; + }; + const expectNoDispatch = (built: BuiltProvider): void => { + expect( + built.calls.filter((call) => call.function.startsWith('_sign')), + ).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect( + built.calls.filter((call) => call.function === '_signChangePubKey'), + ).toStrictEqual([]); + }; + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); - txStatus = 2; - const settledRetry = await built.provider.withdraw({ amount: '25' }); - expect(settledRetry.success).toBe(false); - expect(settledRetry.error).toContain('actually completed'); - const outcomes = await built.provider.getRecoveredDispatches(); - expect(outcomes).toHaveLength(1); - await built.provider.acknowledgeRecoveredDispatch(outcomes[0].recoveryId); - expect((await built.provider.withdraw({ amount: '25' })).success).toBe( - true, + it('retains one original cancellation when exact getTx failure downgrades acknowledgement and the child remains open', async () => { + const env = await retainedCancellation(); + env.restored.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: env.params.cancellation.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: env.params.cancellation.nonce, + status: LIGHTER_TRANSACTION_STATUS.Failed, + }); + const { controller, host } = ownershipController(env.restored.provider); + try { + await controller.init(); + const result = await settled( + host.messenger.call( + 'PerpsController:reconcileChaseOrderCancellation', + env.params, + ), + ); + expect(result).toMatchObject({ + status: 'unresolved', + order: { status: 'termination_pending', restingOrderId: '9001' }, + }); + const current = ( + await env.restored.provider.getNativeChaseRecords() + )[0]; + expect(current.children[0].cancellations).toStrictEqual([ + { ...env.retained.children[0].cancellations[0], phase: 'failed' }, + ]); + expectNoDispatch(env.restored); + // Ordinary explicit cancellation retains its existing financial behavior. + bindRetainedChaseLookup(env.restored, current); + env.restored.clientInstance.getTx.mockImplementation( + async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: Number( + env.restored.calls.find( + (call) => call.function === '_signCancelOrder', + )?.params[3], + ), + status: 2, + }), + ); + expect( + ( + await settled( + env.restored.provider.cancelOrder( + cancelParams(env.params.handle), + ), + ) + ).success, + ).toBe(true); + expect( + env.restored.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(1); + expect(env.restored.clientInstance.sendTx).toHaveBeenCalledTimes(1); + } finally { + await env.restored.provider.disconnect(); + } + }); + it('settles delayed terminal evidence through the public messenger with unchanged original inventory', async () => { + const env = await retainedCancellation(); + const visibleAt = Date.now() + 2000; + bindRetainedChaseLookup( + env.restored, + env.retained, + () => Date.now() >= visibleAt, ); + const { controller, host } = ownershipController(env.restored.provider); + try { + await controller.init(); + expect( + await settled( + host.messenger.call( + 'PerpsController:reconcileChaseOrderCancellation', + env.params, + ), + ), + ).toMatchObject({ + status: 'settled', + providerId: 'lighter', + handle: env.params.handle, + order: { status: 'canceled', restingOrderId: null }, + }); + expect( + (await env.restored.provider.getNativeChaseRecords())[0].children[0] + .cancellations, + ).toStrictEqual(env.retained.children[0].cancellations); + expectNoDispatch(env.restored); + } finally { + await env.restored.provider.disconnect(); + } }); - - 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: [], - }), + it.each([undefined, false])( + 'keeps attempted cancellation with acknowledgement %s unresolved without replay', + async (acknowledged) => { + const env = await retainedCancellation(false); + env.retained.children[0].cancellations[0].acknowledged = acknowledged; + await env.deps.diskCache.setItem( + journalKey, + JSON.stringify({ version: 1, records: [env.retained] }), + ); + env.restored.clientInstance.getTx.mockResolvedValue(null); + try { + expect( + await settled( + env.restored.provider.reconcileChaseOrderCancellation(env.params), + ), + ).toMatchObject({ status: 'unresolved' }); + expect( + (await env.restored.provider.getNativeChaseRecords())[0].children[0] + .cancellations, + ).toHaveLength(1); + expectNoDispatch(env.restored); + } finally { + await env.restored.provider.disconnect(); + } + }, + ); + it('keeps a lost exact venue reply unresolved without changing original inventory', async () => { + const env = await retainedCancellation(); + env.restored.clientInstance.getTx.mockRejectedValue( + new Error('reconciliation reply lost'), ); - const built = buildProvider({ - registeredKey, - platformDependencies: infra, + try { + expect( + await settled( + env.restored.provider.reconcileChaseOrderCancellation(env.params), + ), + ).toMatchObject({ status: 'unresolved' }); + expect( + (await env.restored.provider.getNativeChaseRecords())[0].children[0] + .cancellations, + ).toStrictEqual(env.retained.children[0].cancellations); + expectNoDispatch(env.restored); + } finally { + await env.restored.provider.disconnect(); + } + }); + it.each([ + 'provider', + 'wallet', + 'network', + 'account', + 'key', + 'handle', + 'child', + 'nonce', + 'hash', + 'expiry', + ] as const)( + 'rejects wrong original %s without signing or transport', + async (field) => { + const env = await retainedCancellation(); + const params = structuredClone(env.params); + if (field === 'provider') { + params.providerId = 'hyperliquid'; + } + if (field === 'wallet') { + params.owner.walletAddress = `0x${'f'.repeat(40)}`; + } + if (field === 'network') { + params.owner.network = 'mainnet'; + } + if (field === 'account') { + params.owner.accountIndex = 99; + } + if (field === 'key') { + params.owner.apiKeyIndex = 8; + } + if (field === 'handle') { + params.handle = 'lighter-chase:unknown'; + } + if (field === 'child') { + params.clientOrderId = '102'; + } + if (field === 'nonce') { + params.cancellation.nonce += 1; + } + if (field === 'hash') { + params.cancellation.txHash = 'c'.repeat(64); + } + if (field === 'expiry') { + params.cancellation.expiresAt += 1; + } + try { + await expect( + env.restored.provider.reconcileChaseOrderCancellation(params), + ).rejects.toThrow(/JSON|Chase/u); + expect(env.restored.clientInstance.getTx).not.toHaveBeenCalled(); + expectNoDispatch(env.restored); + } finally { + await env.restored.provider.disconnect(); + } + }, + ); + it.each([ + 'missing cancellation', + 'malformed inventory', + 'missing record', + 'active chase', + ] as const)( + 'rejects %s without venue observation or new cancellation', + async (failure) => { + const env = await retainedCancellation(); + if (failure === 'missing cancellation') { + env.retained.children[0].cancellations = []; + } + if (failure === 'active chase') { + env.retained.status = 'active'; + delete env.retained.stopReason; + } + await env.deps.diskCache.setItem( + journalKey, + failure === 'malformed inventory' + ? '{broken' + : JSON.stringify({ + version: 1, + records: failure === 'missing record' ? [] : [env.retained], + }), + ); + try { + await expect( + env.restored.provider.reconcileChaseOrderCancellation(env.params), + ).rejects.toThrow(/JSON|Chase/u); + expect(env.restored.clientInstance.getTx).not.toHaveBeenCalled(); + expectNoDispatch(env.restored); + } finally { + await env.restored.provider.disconnect(); + } + }, + ); + it('rejects a wallet switch during exact venue reconciliation and preserves pending state', async () => { + const env = await retainedCancellation(); + env.restored.clientInstance.getTx.mockImplementation(async () => { + env.restored.getUserAddressMock.mockReturnValue(`0x${'f'.repeat(40)}`); + return { + code: 200, + hash: env.params.cancellation.txHash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: env.params.cancellation.nonce, + status: 2, + }; }); - 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: 2, - } - : 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); + try { + await expect( + env.restored.provider.reconcileChaseOrderCancellation(env.params), + ).rejects.toThrow(/session|owner|context|switched/iu); + const saved = JSON.parse( + (await env.deps.diskCache.getItem(journalKey)) ?? '', + ) as { records: LighterChaseRecord[] }; + expect(saved.records[0].status).toBe('termination_pending'); + expect(saved.records[0].children[0].cancellations).toStrictEqual( + env.retained.children[0].cancellations, + ); + expectNoDispatch(env.restored); + } finally { + await env.restored.provider.disconnect(); + } }); + }); + type AbsentChaseStops = { + deps: ReturnType; + orderId: string; + retained: LighterChaseRecord; + local: LighterChaseRecord; + }; + const repeatAbsentChaseStops = async ( + first: 'suspension' | 'cancellation', + ): Promise => { + const deps = diskDependencies(); + const original = setup({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + const placed = await original.provider.placeOrder(intent); + if (!placed.orderId || !placed.success) { + throw new Error(placed.error ?? 'Missing Chase placement'); + } + const retained = (await original.provider.getNativeChaseRecords())[0]; + await original.provider.disconnect(); + let local = retained; + for (let restart = 0; restart < 2; restart += 1) { + const absent = absentChase(deps); + try { + for ( + let stop = 0; + stop <= LIGHTER_NATIVE_PROBE_CANCEL_LIMIT; + stop += 1 + ) { + const suspension = (stop % 2 === 0) === (first === 'suspension'); + const result = suspension + ? (await absent.provider.suspendChaseOrders())[0] + : await absent.provider.cancelOrder(cancelParams(placed.orderId)); + const expected = suspension + ? { + handle: placed.orderId, + status: 'termination_pending', + restingOrderId: '9001', + } + : { success: false, error: expect.any(String) as string }; + expect(result).toMatchObject(expected); + } + local = (await absent.provider.getNativeChaseRecords())[0]; + expect(absent.calls).toStrictEqual([]); + expect(absent.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(absent.clientInstance.getApiKeys).not.toHaveBeenCalled(); + expect( + absent.clientInstance.getOrdersByClientIds, + ).not.toHaveBeenCalled(); + expect(local.stopReason).toBe( + first === 'suspension' ? 'backgrounded' : 'canceled', + ); + } finally { + await absent.provider.disconnect(); + } + } + return { deps, orderId: placed.orderId, retained, local }; + }; + const assertRestoredChaseCleanup = async ({ + deps, + orderId, + retained, + local, + }: AbsentChaseStops): Promise => { + const restored = setup({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + bindRetainedChaseLookup(restored, retained); + try { + const cleanup = await restored.provider.cancelOrder( + cancelParams(orderId), + ); + if (!cleanup.success) { + throw new Error(cleanup.error); + } + expect(cleanup.success).toBe(true); + expect(local.children).toStrictEqual(retained.children); + expect(local.error).toContain('authority is unavailable'); + const cancel = restored.calls.filter( + (call) => call.function === '_signCancelOrder', + ); + expect(cancel).toHaveLength(1); + expect(cancel[0].params.slice(0, 3)).toStrictEqual([28, 1, '9001']); + expect( + restored.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(0); + expect( + restored.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(1); + expect( + (await restored.provider.getNativeChaseRecords())[0], + ).toMatchObject({ + intent: retained.intent, + status: 'canceled', + stopReason: local.stopReason, + }); + expect( + (await restored.provider.getChaseOrders())[0].restingOrderId, + ).toBeNull(); + } finally { + await restored.provider.disconnect(); + } + }; + it('preserves cancellation capacity after repeated local suspension across restart', async () => { + const stopped = await repeatAbsentChaseStops('suspension'); - 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, + expect(stopped.local.children).toStrictEqual(stopped.retained.children); + await assertRestoredChaseCleanup(stopped); + }); + it('restores exact cleanup after repeated local cancellation across restart', async () => { + const stopped = await repeatAbsentChaseStops('cancellation'); + + await assertRestoredChaseCleanup(stopped); + expect(stopped.local.children).toStrictEqual(stopped.retained.children); + }); + it.each([ + 'conflict', + 'advanced nonce', + 'invalid nonce', + 'missing order', + 'unknown remaining', + 'legacy unknown', + ] as const)( + 'retains uncertain Chase cancellation after %s and expired absence across restart', + async (evidence) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + try { + const placed = await original.provider.placeOrder(intent); + if (!placed.success || !placed.orderId) { + throw new Error('Missing Chase placement'); + } + const record = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, record, () => false); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), + ); + original.clientInstance.getTx.mockImplementation( + async (hash: string) => { + const signed = original.calls.find( + (call) => call.function === '_signCancelOrder', + ); + return evidence === 'conflict' && signed + ? { + code: 200, + hash, + accountIndex: 99, + apiKeyIndex: 7, + nonce: Number(signed.params[3]), + status: 1, + } + : null; + }, + ); + const first = original.provider.cancelOrder( + cancelParams(placed.orderId), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await first).success).toBe(false); + const retained = (await original.provider.getNativeChaseRecords())[0]; + const attempt = retained.children[0].cancellations[0]; + await original.provider.disconnect(); + if (evidence === 'legacy unknown') { + const key = `lighterChase:${JSON.stringify([retained.intent.owner.network, retained.intent.owner.wallet, retained.intent.owner.accountIndex])}`; + // A version-1 record without acknowledgment evidence is unknown. + const legacy = JSON.parse( + JSON.stringify(retained), + ) as LighterChaseRecord; + Reflect.deleteProperty( + legacy.children[0].cancellations[0], + 'acknowledged', + ); + await deps.diskCache.setItem( + key, + JSON.stringify({ version: 1, records: [legacy] }), + ); + } + jest.setSystemTime( + (attempt.expiresAt ?? 0) + LIGHTER_TX_EXPIRY_SLACK_MS + 1, + ); + const restored = setup({ platformDependencies: deps }); + bindRetainedChaseLookup(restored, retained, () => false); + restored.clientInstance.getTx.mockResolvedValue(null); + restored.clientInstance.getNextNonce.mockResolvedValue({ + code: 200, + nonce: + evidence === 'invalid nonce' + ? Number.NaN + : (attempt.nonce ?? 0) + (evidence === 'advanced nonce' ? 1 : 0), + }); + if (evidence === 'missing order') { + restored.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + } else if (evidence === 'unknown remaining') { + const lookup = + restored.clientInstance.getOrdersByClientIds.getMockImplementation() as ExactLookup; + restored.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + const exact = await lookup(...args); + return { + ...exact, + orders: exact.orders.map((row) => ({ + ...row, + remainingBaseAmount: '', + })), + }; + }, + ); + } + try { + const stopping = restored.provider.cancelOrder( + cancelParams(placed.orderId), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await stopping).success).toBe(false); + expect( + restored.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(0); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + const current = (await restored.provider.getNativeChaseRecords())[0]; + expect(current.children[0].cancellations).toHaveLength(1); + expect(current.children[0].cancellations[0].phase).toBe('attempted'); + expect(current.status).toBe('termination_pending'); + } finally { + await restored.provider.disconnect(); + } + } finally { + await original.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it('retains unknown Chase evidence when conflict persistence fails after an owner switch', async () => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const held = createDeferred(); + const entered = createDeferred(); + const write = jest.spyOn(deps.diskCache, 'setItem').getMockImplementation(); + if (!write) { + throw new Error('Missing journal storage'); + } + try { + const placed = await original.provider.placeOrder(intent); + const record = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, record, () => false); + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), + ); + const first = original.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await first).success).toBe(false); + const retained = (await original.provider.getNativeChaseRecords())[0]; + const dispatch = retained.children[0].cancellations[0]; + expect(dispatch.acknowledged).toBe(false); + original.clientInstance.getTx.mockImplementationOnce(async () => { + entered.resolve(); + await held.promise; + return { + code: 200, + hash: dispatch.txHash ?? '', + accountIndex: 99, + apiKeyIndex: 7, + nonce: dispatch.nonce ?? -1, + status: 1, + }; }); - const venue = setupTriggerVenue(built.clientInstance, built.bridge); - venue.failResponseOnce(13); - expect((await built.provider.withdraw({ amount: '25' })).success).toBe( - false, + const stopping = original.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), ); - expect((await built.provider.withdraw({ amount: '25' })).success).toBe( - false, + await entered.promise; + original.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000099', ); - 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 }], + jest + .spyOn(deps.diskCache, 'setItem') + .mockRejectedValue(new Error('completion storage unavailable')); + held.resolve(); + expect((await stopping).success).toBe(false); + await original.provider.disconnect(); + jest.spyOn(deps.diskCache, 'setItem').mockImplementation(write); + jest.setSystemTime( + (dispatch.expiresAt ?? 0) + LIGHTER_TX_EXPIRY_SLACK_MS + 1, + ); + const restored = setup({ platformDependencies: deps }); + bindRetainedChaseLookup(restored, retained, () => false); + restored.clientInstance.getTx.mockResolvedValue(null); + try { + const saved = (await restored.provider.getNativeChaseRecords())[0]; + expect(saved.children[0].cancellations[0].acknowledged).toBeUndefined(); + const cleanup = restored.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await cleanup).success).toBe(false); + expect( + restored.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await restored.provider.disconnect(); + } + } finally { + held.resolve(); + await original.provider.disconnect(); + jest.spyOn(deps.diskCache, 'setItem').mockImplementation(write); + jest.useRealTimers(); + } + }); + it.each( + (['place', 'cancel'] as const).flatMap((operation) => + (['read', 'parse', 'matching'] as const).map((failure) => ({ + operation, + failure, + })), + ), + )( + 'retains accepted Chase $operation and its nonce after completion $failure failure across restart', + async ({ operation, failure }) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const read = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + if (!read) { + throw new Error('Missing journal storage'); + } + let armed = false; + let injected = false; + let attempted: LighterChaseRecord | undefined; + jest.spyOn(deps.diskCache, 'getItem').mockImplementation(async (key) => { + const raw = await read(key); + if (!armed || !key.startsWith('lighterChase:')) { + return raw; + } + armed = false; + injected = true; + const journal = JSON.parse(raw ?? '') as { + records: LighterChaseRecord[]; + }; + attempted = structuredClone(journal.records[0]); + if (failure === 'read') { + throw new Error('completion read failed'); + } + if (failure === 'parse') { + return '{"version":1,'; + } + const child = journal.records[0].children[0]; + const dispatch = + operation === 'place' ? child.placement : child.cancellations[0]; + if (dispatch.nonce === undefined) { + throw new Error('Missing captured signed nonce'); + } + dispatch.nonce += 1; + return JSON.stringify(journal); }); - // 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({ + try { + let handle = ''; + if (operation === 'cancel') { + const placed = await original.provider.placeOrder(intent); + handle = placed.orderId ?? ''; + const record = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, record, () => false); + } else { + original.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + } + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockImplementationOnce(async () => { + armed = true; + return { code: 200, txHash: 'accepted' }; + }); + const pending = + operation === 'place' + ? original.provider.placeOrder(intent) + : original.provider.cancelOrder(cancelParams(handle)); + await jest.advanceTimersByTimeAsync(10000); + expect((await pending).success).toBe(false); + expect(injected).toBe(true); + if (!attempted) { + throw new Error('Missing captured original attempt'); + } + const saved = (await original.provider.getNativeChaseRecords())[0]; + expect(saved.intent).toStrictEqual(attempted.intent); + expect(saved.status).toBe('termination_pending'); + const dispatch = + operation === 'place' + ? saved.children[0].placement + : saved.children[0].cancellations[0]; + const originalDispatch = + operation === 'place' + ? attempted.children[0].placement + : attempted.children[0].cancellations[0]; + expect(dispatch).toStrictEqual({ + ...originalDispatch, + phase: 'acknowledged', + acknowledged: true, + }); + const ledgerKey = 'lighterNonceLedger:testnet:28:7'; + const ledger = JSON.parse( + (await deps.diskCache.getItem(ledgerKey)) ?? '', + ) as { + entries: unknown[]; + }; + expect(ledger.entries).toHaveLength(1); + expect( + original.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + original.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(operation === 'cancel' ? 1 : 0); + expect(original.clientInstance.sendTx).toHaveBeenCalledTimes( + operation === 'cancel' ? 2 : 1, + ); + await original.provider.disconnect(); + jest.setSystemTime( + (dispatch.expiresAt ?? 0) + LIGHTER_TX_EXPIRY_SLACK_MS + 1, + ); + const restored = setup({ platformDependencies: deps }); + restored.clientInstance.getTx.mockResolvedValue(null); + if (operation === 'cancel') { + bindRetainedChaseLookup(restored, saved, () => false); + } else { + restored.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + } + try { + expect( + (await restored.provider.getNativeChaseRecords())[0], + ).toStrictEqual(saved); + const cleanup = restored.provider.cancelOrder( + cancelParams(saved.intent.handle), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await cleanup).success).toBe(false); + const retained = (await restored.provider.getNativeChaseRecords())[0]; + expect(retained.status).toBe('termination_pending'); + expect( + operation === 'place' + ? retained.children[0].placement + : retained.children[0].cancellations[0], + ).toStrictEqual(dispatch); + expect( + restored.calls.filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ), + ).toHaveLength(0); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + const retainedLedger = JSON.parse( + (await deps.diskCache.getItem(ledgerKey)) ?? '', + ) as { entries: unknown[] }; + expect(retainedLedger.entries).toStrictEqual(ledger.entries); + } finally { + await restored.provider.disconnect(); + } + } finally { + await original.provider.disconnect(); + jest.spyOn(deps.diskCache, 'getItem').mockImplementation(read); + jest.useRealTimers(); + } + }, + ); + it.each( + (['place', 'cancel'] as const).flatMap((operation) => + ( + [ + 'account switch', + 'network switch', + 'reset', + 'storage failure', + ] as const + ).map((boundary) => ({ operation, boundary })), + ), + )( + 'saves accepted Chase $operation before $boundary and nonce retirement', + async ({ operation, boundary }) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const entered = createDeferred(); + const held = createDeferred(); + let switchedInventory: LighterChaseRecord[] = []; + let switchedCallCount = 0; + let captured: LighterChaseRecord | undefined; + const write = jest + .spyOn(deps.diskCache, 'setItem') + .getMockImplementation(); + if (!write) { + throw new Error('Missing journal storage'); + } + jest + .spyOn(deps.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + if (key.startsWith('lighterChase:')) { + const journal = JSON.parse(value) as { + records: LighterChaseRecord[]; + }; + captured = journal.records[0]; + const dispatch = + operation === 'place' + ? captured.children[0]?.placement + : captured.children[0]?.cancellations.at(-1); + if ( + boundary === 'storage failure' && + dispatch?.phase === 'acknowledged' + ) { + throw new Error('acknowledgment storage unavailable'); + } + } + await write(key, value); + }); + try { + let handle = ''; + if (operation === 'cancel') { + const placed = await original.provider.placeOrder(intent); + handle = placed.orderId ?? ''; + const record = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, record, () => false); + } + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockImplementationOnce(async () => { + entered.resolve(); + await held.promise; + return { code: 200, txHash: 'accepted' }; + }); + const pending = + operation === 'place' + ? original.provider.placeOrder(intent) + : original.provider.cancelOrder(cancelParams(handle)); + await entered.promise; + if (boundary === 'account switch') { + original.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000099', + ); + } else if (boundary === 'network switch') { + await original.provider.disconnect(); + const mainnet = buildProvider({ + platformDependencies: deps, + isTestnet: false, + chaseTestnetProbe: false, + nativeTwapTestnetProbe: false, + }); + switchedInventory = await mainnet.provider.getNativeChaseRecords(); + switchedCallCount = mainnet.calls.length; + await mainnet.provider.disconnect(); + } else if (boundary === 'reset') { + original.fireReset(); + } + held.resolve(); + await jest.advanceTimersByTimeAsync(10000); + expect((await pending).success).toBe(false); + expect(switchedInventory).toStrictEqual([]); + expect(switchedCallCount).toBe(0); + if (!captured) { + throw new Error('Missing captured original owner'); + } + const key = `lighterChase:${JSON.stringify([captured.intent.owner.network, captured.intent.owner.wallet, captured.intent.owner.accountIndex])}`; + const raw = await deps.diskCache.getItem(key); + const saved = ( + JSON.parse(raw ?? '') as { records: LighterChaseRecord[] } + ).records[0]; + const dispatch = + operation === 'place' + ? saved.children[0].placement + : saved.children[0].cancellations[0]; + expect(dispatch.phase).toBe( + boundary === 'storage failure' ? 'attempted' : 'acknowledged', + ); + const ledger = JSON.parse( + (await deps.diskCache.getItem('lighterNonceLedger:testnet:28:7')) ?? + '', + ) as { entries: unknown[] }; + expect(ledger.entries).toHaveLength( + boundary === 'storage failure' ? 1 : 0, + ); + await original.provider.disconnect(); + jest.spyOn(deps.diskCache, 'setItem').mockImplementation(write); + jest.setSystemTime( + (dispatch.expiresAt ?? 0) + LIGHTER_TX_EXPIRY_SLACK_MS + 1, + ); + const restored = setup({ platformDependencies: deps }); + restored.clientInstance.getTx.mockResolvedValue(null); + if (operation === 'cancel') { + bindRetainedChaseLookup(restored, saved, () => false); + } else { + restored.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + } + try { + const cleanup = restored.provider.cancelOrder( + cancelParams(saved.intent.handle), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await cleanup).success).toBe(false); + expect( + (await restored.provider.getNativeChaseRecords())[0].status, + ).toBe('termination_pending'); + expect( + restored.calls.filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ), + ).toHaveLength(0); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await restored.provider.disconnect(); + } + } finally { + held.resolve(); + await original.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it('retries an unacknowledged Chase cancellation only with complete never-landed proof', async () => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + try { + const placed = await original.provider.placeOrder(intent); + const record = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, record, () => false); + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), + ); + const first = original.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await first).success).toBe(false); + const retained = (await original.provider.getNativeChaseRecords())[0]; + const dispatch = retained.children[0].cancellations[0]; + expect(dispatch).toMatchObject({ + phase: 'attempted', + acknowledged: false, + }); + await original.provider.disconnect(); + jest.setSystemTime( + (dispatch.expiresAt ?? 0) + LIGHTER_TX_EXPIRY_SLACK_MS + 1, + ); + const restored = setup({ platformDependencies: deps }); + bindRetainedChaseLookup(restored, retained); + restored.clientInstance.getTx.mockResolvedValue(null); + restored.clientInstance.getNextNonce.mockResolvedValue({ code: 200, - l1Address: ACCOUNT.l1Address, - subAccounts: [ACCOUNT], + nonce: dispatch.nonce ?? -1, }); - 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', + try { + expect( + await restored.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ), + ).toMatchObject({ success: true }); + const settled = (await restored.provider.getNativeChaseRecords())[0]; + expect(settled.children[0].cancellations).toHaveLength(2); + expect(settled.children[0].cancellations[0].phase).toBe('failed'); + expect( + restored.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + restored.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(1); + } finally { + await restored.provider.disconnect(); + } + } finally { + await original.provider.disconnect(); + jest.useRealTimers(); + } + }); + it.each(['place', 'cancel'] as const)( + 'saves exact-seen Chase %s acceptance across an owner switch', + async (operation) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const held = createDeferred(); + const entered = createDeferred(); + try { + let handle: string; + let initialSuccess: boolean | undefined; + if (operation === 'place') { + original.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), + ); + const placed = await original.provider.placeOrder(intent); + handle = placed.orderId ?? ''; + initialSuccess = placed.success; + } else { + const placed = await original.provider.placeOrder(intent); + handle = placed.orderId ?? ''; + const record = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, record, () => false); + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('response lost'), + ); + const first = original.provider.cancelOrder(cancelParams(handle)); + await jest.advanceTimersByTimeAsync(10000); + initialSuccess = (await first).success; + } + expect(initialSuccess).toBe(false); + const retained = (await original.provider.getNativeChaseRecords())[0]; + const dispatch = + operation === 'place' + ? retained.children[0].placement + : retained.children[0].cancellations[0]; + original.clientInstance.getTx.mockImplementationOnce(async () => { + entered.resolve(); + await held.promise; + return { + code: 200, + hash: dispatch.txHash ?? '', + accountIndex: retained.intent.owner.accountIndex, + apiKeyIndex: retained.intent.owner.apiKeyIndex, + nonce: dispatch.nonce ?? -1, + status: LIGHTER_TRANSACTION_STATUS.Pending, + }; + }); + const stopping = original.provider.cancelOrder(cancelParams(handle)); + await entered.promise; + original.getUserAddressMock.mockReturnValue( + '0x0000000000000000000000000000000000000099', + ); + held.resolve(); + expect((await stopping).success).toBe(false); + await original.provider.disconnect(); + jest.setSystemTime( + (dispatch.expiresAt ?? 0) + LIGHTER_TX_EXPIRY_SLACK_MS + 1, + ); + const restored = setup({ platformDependencies: deps }); + restored.clientInstance.getTx.mockResolvedValue(null); + if (operation === 'cancel') { + bindRetainedChaseLookup(restored, retained, () => false); + } else { + restored.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + } + try { + const record = (await restored.provider.getNativeChaseRecords())[0]; + expect( + operation === 'place' + ? record.children[0].placement + : record.children[0].cancellations[0], + ).toMatchObject({ phase: 'acknowledged', acknowledged: true }); + const cleanup = restored.provider.cancelOrder(cancelParams(handle)); + await jest.advanceTimersByTimeAsync(10000); + expect((await cleanup).success).toBe(false); + expect( + restored.calls.filter((call) => + ['_signCreateOrder', '_signCancelOrder'].includes(call.function), + ), + ).toHaveLength(0); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await restored.provider.disconnect(); + } + } finally { + held.resolve(); + await original.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each([ + LIGHTER_TRANSACTION_STATUS.Pending, + LIGHTER_TRANSACTION_STATUS.PendingFinal, + LIGHTER_TRANSACTION_STATUS.Executed, + ])( + 'keeps exact-seen Chase acceptance after response loss, expired absence and restart outcome=%s', + async (outcome) => { + jest.useFakeTimers(); + try { + const deps = diskDependencies(); + const original = setup({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + const placed = await original.provider.placeOrder(intent); + if (!placed.orderId || !placed.success) { + throw new Error(placed.error ?? 'Missing Chase placement'); + } + const handle = placed.orderId; + const retained = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, retained, () => false); + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('cancel response lost'), + ); + const cancel = (): ReturnType => + original.provider.cancelOrder(cancelParams(handle)); + const firstCancellation = cancel(); + await jest.advanceTimersByTimeAsync(10000); + expect((await firstCancellation).success).toBe(false); + const attempted = (await original.provider.getNativeChaseRecords())[0]; + const dispatch = attempted.children[0].cancellations[0]; + if ( + !dispatch.expiresAt || + !dispatch.txHash || + dispatch.nonce === undefined + ) { + throw new Error('Missing Chase cancellation identity'); + } + expect(dispatch.phase).toBe('attempted'); + original.clientInstance.getTx.mockResolvedValue({ + code: 200, + hash: dispatch.txHash, + accountIndex: attempted.intent.owner.accountIndex, + apiKeyIndex: attempted.intent.owner.apiKeyIndex, + nonce: dispatch.nonce, + status: outcome, + }); + const observingAcceptance = cancel(); + await jest.advanceTimersByTimeAsync(10000); + expect((await observingAcceptance).success).toBe(false); + const seen = (await original.provider.getNativeChaseRecords())[0]; + await original.provider.disconnect(); + await jest.advanceTimersByTimeAsync( + dispatch.expiresAt + LIGHTER_TX_EXPIRY_SLACK_MS - Date.now() + 1, + ); + for (let restart = 0; restart < 2; restart += 1) { + const restored = setup({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + bindRetainedChaseLookup(restored, attempted, () => false); + restored.clientInstance.getTx.mockResolvedValue(null); + try { + const cancellation = restored.provider.cancelOrder( + cancelParams(placed.orderId), ); - if (failedAt >= 0) { - const [failedRow] = venue.rawTriggers.splice(failedAt, 1); - venue.rawInactive.push({ ...failedRow, status: 'canceled' }); - } + await jest.advanceTimersByTimeAsync(10000); + expect((await cancellation).success).toBe(false); + const current = ( + await restored.provider.getNativeChaseRecords() + )[0]; + expect(current.children[0].cancellations).toStrictEqual([ + { ...dispatch, phase: 'acknowledged', acknowledged: true }, + ]); + expect(current.executedSize).toBe(attempted.executedSize); + expect(current.executedNotional).toBe(attempted.executedNotional); + expect( + restored.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toStrictEqual([]); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await restored.provider.disconnect(); } - return result; - }, - ); - const parked = await provider.updatePositionTPSL({ - symbol: 'BTC', - takeProfitPrice: '111000', - stopLossPrice: '81000', + } + expect(seen.children[0].cancellations).toStrictEqual([ + { ...dispatch, phase: 'acknowledged', acknowledged: true }, + ]); + expect( + original.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + original.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(1); + } finally { + jest.useRealTimers(); + } + }, + ); + it('preserves a real attempted cancellation through absent stops and refuses an unknown duplicate after restart', async () => { + jest.useFakeTimers(); + try { + const deps = diskDependencies(); + const original = setup({ + platformDependencies: deps, + configuredAccountIndex: null, }); - 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', + const placed = await original.provider.placeOrder(intent); + if (!placed.orderId || !placed.success) { + throw new Error(placed.error ?? 'Missing Chase placement'); + } + const retained = (await original.provider.getNativeChaseRecords())[0]; + bindRetainedChaseLookup(original, retained, () => false); + original.clientInstance.getTx.mockResolvedValue(null); + original.clientInstance.sendTx.mockRejectedValueOnce( + new Error('cancel response lost'), + ); + const firstCancellation = original.provider.cancelOrder( + cancelParams(placed.orderId), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await firstCancellation).success).toBe(false); + const attempted = (await original.provider.getNativeChaseRecords())[0]; + expect(attempted.children[0].cancellations[0]).toMatchObject({ + phase: 'attempted', + txHash: expect.any(String) as string, }); - 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( + original.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + original.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(1); + await original.provider.disconnect(); + for (let restart = 0; restart < 2; restart += 1) { + const absent = absentChase(deps); + try { + for ( + let stop = 0; + stop <= LIGHTER_NATIVE_PROBE_CANCEL_LIMIT; + stop += 1 + ) { + await absent.provider.suspendChaseOrders(); + await absent.provider.cancelOrder(cancelParams(placed.orderId)); + } + expect( + (await absent.provider.getNativeChaseRecords())[0].children, + ).toStrictEqual(attempted.children); + expect(absent.calls).toStrictEqual([]); + expect(absent.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await absent.provider.disconnect(); + } + } + const restored = setup({ + platformDependencies: deps, + configuredAccountIndex: null, }); - 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, + let terminal = false; + bindRetainedChaseLookup(restored, attempted, () => terminal); + const dispatch = attempted.children[0].cancellations[0]; + restored.clientInstance.getTx.mockImplementation( + async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: dispatch.nonce, + status: terminal ? 2 : 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); + try { + const retainedCancellation = restored.provider.cancelOrder( + cancelParams(placed.orderId), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await retainedCancellation).success).toBe(false); + expect( + (await restored.provider.getNativeChaseRecords())[0].children[0] + .cancellations, + ).toStrictEqual([ + { ...dispatch, phase: 'acknowledged', acknowledged: true }, + ]); + terminal = true; + expect( + (await restored.provider.cancelOrder(cancelParams(placed.orderId))) + .success, + ).toBe(true); + expect( + restored.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(restored.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await restored.provider.disconnect(); + } + } finally { + jest.useRealTimers(); + } + }); + it.each(['empty', 'other-wallet', 'other-network'] as const)( + 'isolates remembered Chase ownership after absent-account restart: %s', + async (state) => { + const restarted = await restartAbsentChase(state); + try { + const orders = await restarted.provider.getChaseOrders(); + const suspended = await restarted.provider.suspendChaseOrders(); + + expect(orders).toStrictEqual([]); + expect(suspended).toStrictEqual([]); + expect(restarted.calls).toHaveLength(0); + expect(restarted.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(restarted.clientInstance.getApiKeys).not.toHaveBeenCalled(); + } finally { + await restarted.provider.disconnect(); + } + }, + ); + it.each(['malformed JSON', 'duplicate identity', 'unreadable'] as const)( + 'refuses a %s remembered Chase identity after absent-account restart', + async (state) => { + const deps = diskDependencies(); + const key = `lighterRecoveryAccounts:testnet:${ACCOUNT.l1Address.toLowerCase()}`; + await deps.diskCache.setItem( + key, + state === 'malformed JSON' ? '{' : JSON.stringify([28, 28]), + ); + if (state === 'unreadable') { + const original = jest + .spyOn(deps.diskCache, 'getItem') + .getMockImplementation(); + if (!original) { + throw new Error('Missing disk read implementation'); + } + jest + .spyOn(deps.diskCache, 'getItem') + .mockImplementation(async (name) => { + if (name === key) { + throw new Error('remembered Chase identity storage unavailable'); + } + return await original(name); + }); + } + const built = buildProvider({ + platformDependencies: deps, + configuredAccountIndex: null, + }); + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [], + }); + const expected = { + 'malformed JSON': /JSON|property|position/u, + 'duplicate identity': 'recovery account index is corrupt', + unreadable: 'remembered Chase identity storage unavailable', + }[state]; + try { + await expect(built.provider.getChaseOrders()).rejects.toThrow(expected); + await expect(built.provider.suspendChaseOrders()).rejects.toThrow( + expected, + ); + expect(built.calls).toStrictEqual([]); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + expect(built.clientInstance.getApiKeys).not.toHaveBeenCalled(); + } finally { + await built.provider.disconnect(); + } + }, + ); + it.each(['settled', 'later-settled', 'unresolved'] as const)( + 'reports the requested Chase cleanup independently of its earlier failure: %s', + async (state) => { + jest.useFakeTimers(); + const built = setup(); + const original = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!original) { + throw new Error('Missing exact child implementation'); + } + let terminal = state === 'settled'; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + const response = await original(...args); + return terminal + ? response + : { + ...response, + orders: response.orders.map((order) => ({ + ...order, + status: 'open', + })), + }; }, ); - 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 }); - }); + try { + const placed = await built.provider.placeOrder(intent); + if (!placed.orderId) { + throw new Error('Missing handle'); + } + built.clientInstance.getOrderBookOrders.mockRejectedValue( + new Error('quote unavailable'), + ); + await jest.advanceTimersByTimeAsync(11000); + const before = (await built.provider.getNativeChaseRecords())[0]; + expect(before.stopReason).toBe('failed'); + if (state === 'later-settled') { + terminal = true; + } - 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, - }, + const cancellation = built.provider.cancelOrder( + cancelParams(placed.orderId), + ); + await jest.advanceTimersByTimeAsync(10000); + const receipt = await cancellation; + + const after = (await built.provider.getNativeChaseRecords())[0]; + expect(after.stopReason).toBe('failed'); + const pendingError = + 'Lighter Chase exact cancellation remains unresolved'; + expect(receipt).toStrictEqual({ + success: state !== 'unresolved', + orderId: placed.orderId, + providerId: 'lighter', + ...(state === 'unresolved' ? { error: pendingError } : {}), + }); + expect(after.status).toBe( + state === 'unresolved' ? 'termination_pending' : 'failed', + ); + expect(after.error).toBe( + state === 'unresolved' ? pendingError : before.error, + ); + expect((await built.provider.getChaseOrders())[0].restingOrderId).toBe( + state === 'unresolved' ? '9001' : null, + ); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each(['absent', 'unbound'] as const)( + 'returns empty default-off Chase management for an %s wallet', + async (state) => { + const built = buildProvider({ configuredAccountIndex: null }); + if (state === 'absent') { + built.clientInstance.getAccountsByL1Address.mockResolvedValue({ + code: 200, + l1Address: ACCOUNT.l1Address, + subAccounts: [], + }); + } else { + built.getUserAddressMock.mockImplementation(() => { + throw new Error('No selected wallet'); + }); + } + expect(await built.provider.getChaseOrders()).toStrictEqual([]); + expect(await built.provider.suspendChaseOrders()).toStrictEqual([]); + expect( + await built.provider.cancelOrder(cancelParams('lighter-chase:unknown')), + ).toMatchObject({ success: false }); + expect(built.calls).toHaveLength(0); + await built.provider.disconnect(); + }, + ); + it('retains an accepted placement through exact-row indexer lag and observes it on the next tick', async () => { + jest.useFakeTimers(); + const built = setup(); + const exact = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!exact) { + throw new Error('Missing exact lookup'); + } + let visible = false; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => + visible ? await exact(...args) : { code: 200, orders: [] }, + ); + built.clientInstance.getTx.mockResolvedValue(null); + try { + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(true); + expect((await built.provider.getChaseOrders())[0].status).toBe('active'); + visible = true; + await jest.advanceTimersByTimeAsync(1000); + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'active', + restingOrderId: '9001', }); - second.clientInstance.getAccountByIndex.mockResolvedValue({ + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + it.each(['open', 'canceled'] as const)( + 'recovers a legacy nonce-mismatch Chase child that is now %s without replacing it', + async (status) => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + if (!placed.orderId || !placed.success) { + throw new Error('Missing placed Chase handle'); + } + const record = (await original.provider.getNativeChaseRecords())[0]; + const child = record.children[0]; + const lookup = + original.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!lookup) { + throw new Error('Missing exact lookup'); + } + const exact = await lookup( + record.intent.owner.accountIndex, + 'test-token', + [child.clientOrderId], + ); + await original.provider.disconnect(); + const key = `lighterChase:${JSON.stringify([ + record.intent.owner.network, + record.intent.owner.wallet, + record.intent.owner.accountIndex, + ])}`; + delete child.observation; + record.status = 'termination_pending'; + record.stopReason = 'failed'; + record.error = 'Lighter Chase child identity mismatch'; + await deps.diskCache.setItem( + key, + JSON.stringify({ version: 1, records: [record] }), + ); + const restarted = setup({ platformDependencies: deps }); + restarted.clientInstance.getOrdersByClientIds.mockImplementation( + async () => ({ + code: 200, + orders: exact.orders.map((row) => ({ + ...row, + status: restarted.calls.some( + (call) => call.function === '_signCancelOrder', + ) + ? 'canceled' + : status, + })), + }), + ); + try { + expect( + await restarted.provider.cancelOrder(cancelParams(placed.orderId)), + ).toMatchObject({ success: true }); + expect( + (await restarted.provider.getNativeChaseRecords())[0], + ).toMatchObject({ + status: 'failed', + stopReason: 'failed', + executedSize: '0', + children: [{ observation: { orderId: '9001', terminal: true } }], + }); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateOrder', + ), + ).toHaveLength(0); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(status === 'open' ? 1 : 0); + } finally { + await restarted.provider.disconnect(); + } + }, + ); + it.each(['hash', 'accountIndex', 'apiKeyIndex', 'nonce'] as const)( + 'refuses a foreign %s in Chase placement transaction evidence', + async (field) => { + const built = setup(); + built.clientInstance.getOrdersByClientIds.mockResolvedValue({ code: 200, - accounts: [{ ...ACCOUNT, index: 99 }], + orders: [], + }); + built.clientInstance.getTx.mockImplementation(async (hash: string) => { + const create = built.calls.find( + (call) => call.function === '_signCreateOrder', + ); + if (!create) { + throw new Error('Missing signed create order'); + } + const transaction = { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: Number(create.params[11]), + status: 2, + }; + return { + ...transaction, + [field]: field === 'hash' ? 'foreign' : transaction[field] + 1, + }; }); - 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(); + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject( + { + status: 'termination_pending', + error: 'Lighter Chase transaction identity mismatch', + }, + ); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); } finally { - nowSpy.mockRestore(); + await built.provider.disconnect(); } - // 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)[])[1]); + }, + ); + it.each(['failed', 'expired'] as const)( + 'settles a %s never-landed Chase placement across restart', + async (outcome) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + built.clientInstance.sendTx.mockImplementation(async (kind: number) => { + if (kind === LIGHTER_TX_TYPE_CREATE_ORDER) { + throw new Error('response lost'); } - if (call.function === '_createAuthToken') { - const minter = Number((call.params as (string | number)[])[0]); - if (currentOwner !== null && currentOwner !== minter) { - mismatches.push(`${String(currentOwner)}!=${String(minter)}`); - } + return { code: 200, txHash: 'registered' }; + }); + try { + const result = await built.provider.placeOrder(intent); + expect(result.success).toBe(false); + await built.provider.disconnect(); + const restarted = setup({ platformDependencies: deps }); + const restartedExact = + restarted.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + restarted.clientInstance.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + const record = (await restarted.provider.getNativeChaseRecords())[0]; + const dispatch = record.children[0].placement; + restarted.clientInstance.getTx.mockImplementation( + async (hash: string) => + hash === dispatch.txHash && outcome === 'failed' + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: dispatch.nonce, + status: 0, + } + : null, + ); + if (outcome === 'expired') { + jest.setSystemTime((dispatch.expiresAt ?? 0) + 30001); + } + expect( + await restarted.provider.cancelOrder( + cancelParams(result.orderId ?? ''), + ), + ).toMatchObject({ success: true }); + expect( + (await restarted.provider.getNativeChaseRecords())[0], + ).toMatchObject({ + status: 'canceled', + children: [{ placement: { phase: 'failed' }, cancellations: [] }], + }); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(0); + if (!restartedExact) { + throw new Error('Missing exact lookup'); } + restarted.clientInstance.getOrdersByClientIds.mockImplementation( + restartedExact, + ); + expect(await restarted.provider.placeOrder(intent)).toMatchObject({ + success: true, + }); + await restarted.provider.disconnect(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); } - expect(mismatches).toStrictEqual([]); - }); + }, + ); + it.each([0, 3, null] as const)( + 'settles a terminal child independently of cancellation outcome %s', + async (outcome) => { + jest.useFakeTimers(); + const built = setup(); + try { + const result = await built.provider.placeOrder(intent); + const tx = built.clientInstance.getTx.getMockImplementation() as + | TxLookup + | undefined; + if (!tx) { + throw new Error('Missing transaction lookup'); + } + built.clientInstance.getTx.mockImplementation(async (hash: string) => + outcome === null ? null : { ...(await tx(hash)), status: outcome }, + ); + if (outcome === null) { + built.clientInstance.sendTx.mockImplementation( + async (kind: number) => { + if (kind === LIGHTER_TX_TYPE_CANCEL_ORDER) { + throw new Error('rejected cancel'); + } + return { code: 200, txHash: 'registered' }; + }, + ); + } + const canceled = await built.provider.cancelOrder( + cancelParams(result.orderId ?? ''), + ); + expect(canceled.success).toBe(true); + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'canceled', + restingOrderId: null, + }); + if (outcome === null) { + const cancel = (await built.provider.getNativeChaseRecords())[0] + .children[0].cancellations[0]; + jest.setSystemTime((cancel.expiresAt ?? 0) + 30001); + } + expect(await built.provider.placeOrder(intent)).toMatchObject({ + success: true, + }); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each(['failed', 'unknown'] as const)( + 'allows cleanup retry only for a definitively %s cancellation despite unavailable fills', + async (outcome) => { + jest.useFakeTimers(); + try { + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + const exact = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!exact) { + throw new Error('Missing exact lookup'); + } + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + const result = (await exact(...args)) as { + code: number; + orders: LighterApiOrder[]; + }; + return { + ...result, + orders: result.orders.map((row) => ({ ...row, status: 'open' })), + }; + }, + ); + const placed = await built.provider.placeOrder(intent); + const cancellation = built.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + await jest.advanceTimersByTimeAsync(10000); + expect((await cancellation).success).toBe(false); + const original = (await built.provider.getNativeChaseRecords())[0]; + await built.provider.disconnect(); + const restarted = setup({ platformDependencies: deps }); + restarted.clientInstance.getOrdersByClientIds.mockImplementation( + async (_account: number, _token: string, ids: string[]) => ({ + code: 200, + orders: [ + { + orderIndex: 9001, + orderId: '9001', + clientOrderIndex: Number(ids[0]), + clientOrderId: ids[0], + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: original.children[0].size, + remainingBaseAmount: original.children[0].size, + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: original.children[0].price, + isAsk: false, + type: 'limit', + timeInForce: 'post-only', + reduceOnly: 0, + status: 'open', + orderExpiry: Date.now() + 100000, + timestamp: 1, + nonce: 2075941, + }, + ], + }), + ); + restarted.clientInstance.getTrades.mockRejectedValue( + new Error('fills unavailable'), + ); + const previous = original.children[0].cancellations[0]; + restarted.clientInstance.getTx.mockImplementation( + async (hash: string) => + hash === previous.txHash + ? { + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: previous.nonce, + status: outcome === 'failed' ? 0 : 1, + } + : null, + ); + await restarted.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(outcome === 'failed' ? 1 : 0); + const updated = (await restarted.provider.getNativeChaseRecords())[0]; + expect(updated.children[0].cancellations).toHaveLength( + outcome === 'failed' ? 2 : 1, + ); + expect(updated.status).toBe('termination_pending'); + await restarted.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + expect( + restarted.calls.filter( + (call) => call.function === '_signCancelOrder', + ), + ).toHaveLength(outcome === 'failed' ? 1 : 0); + await restarted.provider.disconnect(); + } finally { + jest.useRealTimers(); + } + }, + ); + it('cleans up at an absolute non-divisible deadline', async () => { + jest.useFakeTimers(); + const built = setup(); + try { + await built.provider.placeOrder({ + ...intent, + chaseIntervalMs: 120000, + chaseMaxDurationMs: 180000, + chaseMaxRepricings: 5, + }); + await jest.advanceTimersByTimeAsync(179999); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + await jest.advanceTimersByTimeAsync(1); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'canceled', + restingOrderId: null, + }); + expect((await built.provider.getNativeChaseRecords())[0].stopReason).toBe( + 'duration_reached', + ); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } }); - 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: [ + it.each(['quote', 'sign'] as const)( + 'invalidates a replacement before queued discovery while %s is pending', + async (phase) => { + jest.useFakeTimers(); + const built = setup(); + const pending = createDeferred(); + const entered = createDeferred(); + try { + const placed = await built.provider.placeOrder(intent); + const book = + built.clientInstance.getOrderBookOrders.getMockImplementation(); + const snapshot = (await built.clientInstance.getOrderBookOrders(1)) as { + code: number; + totalBids: number; + totalAsks: number; + bids: { price: string }[]; + asks: { price: string }[]; + }; + built.clientInstance.getOrderBookOrders.mockImplementation(async () => { + if (phase === 'quote') { + entered.resolve(); + await pending.promise; + } + return { + ...snapshot, + bids: snapshot.bids.map((row) => ({ ...row, price: '99999.9' })), + }; + }); + if (phase === 'sign') { + const executeSpy = jest.spyOn(built.bridge, 'execute'); + const execute = executeSpy.getMockImplementation(); + if (!execute) { + throw new Error('Missing signer implementation'); + } + jest + .spyOn(built.bridge, 'execute') + .mockImplementation(async (call) => { + if (call.function === '_signCreateOrder') { + entered.resolve(); + await pending.promise; + } + return await execute(call); + }); + } + const ticking = jest.advanceTimersByTimeAsync(1000); + await entered.promise; + const canceling = built.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + pending.resolve(); + await ticking; + await canceling; + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ), + ).toHaveLength(1); + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'canceled', + restingOrderId: null, + }); + expect( + (await built.provider.getNativeChaseRecords())[0].children[0] + .cancellations, + ).toHaveLength(1); + if (book) { + built.clientInstance.getOrderBookOrders.mockImplementation(book); + } + } finally { + pending.resolve(); + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it.each(['chase', 'twap'] as const)( + 'serializes concurrent opposite-strategy starts across live instances with %s first', + async (first) => { + const deps = diskDependencies(); + const a = setup({ platformDependencies: deps }); + const b = setup({ platformDependencies: deps }); + for (const built of [a, b]) { + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ { - recoveryId: '41:beef', - kind: 13, - intent: 'withdraw:9', - txHash: 'beef', - outcome: 'failed', - evidence: 'tx-status:0', + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, }, ], - }), - ); - 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({ + }); + } + const twap: OrderParams = { symbol: 'BTC', + orderType: 'twap', 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; + size: '0.00020', + leverage: 1, + twapDuration: 1, + }; + try { + const results = await Promise.all([ + a.provider.placeOrder(first === 'chase' ? intent : twap), + b.provider.placeOrder(first === 'chase' ? twap : intent), + ]); + const aCreates = a.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ).length; + const bCreates = b.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ).length; + expect(aCreates + bCreates).toBe(1); + expect(results).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + success: false, + error: expect.stringContaining( + 'native probe remains unresolved', + ) as string, + }), + ]), + ); + } finally { + await a.provider.disconnect(); + await b.provider.disconnect(); + } + }, + ); + it.each([0, 3, null] as const)( + 'settles fill-wins-cancel race with %s transaction outcome and unequal trades across pages', + async (outcome) => { + const built = setup(); + const original = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!original) { + throw new Error('Missing exact lookup'); + } + let filled = false; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + const result = (await original(...args)) as { + code: number; + orders: LighterApiOrder[]; + }; + return { + ...result, + orders: result.orders.map((row) => + filled + ? { + ...row, + status: 'filled', + filledBaseAmount: '0.0002', + filledQuoteAmount: '19.99998', + remainingBaseAmount: '0', + } + : row, + ), + }; }, ); - 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], + built.clientInstance.getTrades.mockImplementation( + async ( + _account: number, + _token: string, + options: { cursor?: string }, + ) => + filled + ? { + code: 200, + trades: [ + { + tradeId: options.cursor ? 2 : 1, + txHash: options.cursor ? 'cdefcdef' : 'abcdabcd', + marketId: 1, + size: options.cursor ? '0.00015' : '0.00005', + price: '99999.9', + usdAmount: options.cursor ? '14.999985' : '4.999995', + bidId: 9001, + askId: 9002, + bidAccountId: 28, + askAccountId: 99, + isMakerAsk: false, + timestamp: 100001, + type: 'trade', + takerPositionSizeBefore: '0', + makerPositionSizeBefore: '0', + }, + ], + ...(options.cursor ? {} : { nextCursor: 'fill-page-2' }), + } + : { code: 200, trades: [] }, + ); + built.clientInstance.sendTx.mockImplementation(async (kind: number) => { + if (kind === LIGHTER_TX_TYPE_CANCEL_ORDER) { + filled = true; + if (outcome === null) { + throw new Error('response lost after fill'); + } + } + return { code: 200, txHash: 'accepted' }; }); - 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, + const tx = built.clientInstance.getTx.getMockImplementation() as + | TxLookup + | undefined; + if (!tx) { + throw new Error('Missing transaction lookup'); + } + built.clientInstance.getTx.mockImplementation(async (hash: string) => + outcome === null ? null : { ...(await tx(hash)), status: outcome }, ); - }); - }); - describe('per-market minimums (dynamic, venue-derived)', () => { - it('getMarkets reports the universal one-tick USD minimum rather than the maker-only floor', async () => { - const { provider } = buildProvider(); - const markets = await provider.getMarkets(); - const btc = markets.find((market) => market.name === 'BTC'); - // Lighter's base/quote floors are maker-only. Market and IOC orders - // may use one base-size tick: 0.00001 BTC x $100000 = $1. - expect(btc?.minimumOrderSize).toBe(1); - 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, + try { + const result = await built.provider.placeOrder(intent); + const canceled = await built.provider.cancelOrder( + cancelParams(result.orderId ?? ''), + ); + if (!canceled.success) { + throw new Error(canceled.error); + } + expect(canceled.success).toBe(true); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject( + { + status: 'filled', + executedSize: '0.0002', + executedNotional: '19.99998', + }, + ); + expect((await built.provider.getChaseOrders())[0]).toMatchObject({ + status: 'filled', + restingOrderId: null, + remainingSize: '0', + }); + const tradeCalls = built.clientInstance.getTrades.mock + .calls as Parameters[]; + expect( + tradeCalls.some((args) => args[2].cursor === 'fill-page-2'), + ).toBe(true); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + } + }, + ); + it.each(['repeat', 'capacity'] as const)( + 'keeps incomplete %s Chase trade pagination pending', + async (mode) => { + const built = setup(); + const placed = await built.provider.placeOrder(intent); + let page = 0; + built.clientInstance.getTrades.mockImplementation(async () => { + page += 1; + return { + code: 200, + trades: [{ tradeId: page }], + nextCursor: mode === 'repeat' ? 'repeat' : String(page), + }; }); - expect(validation.isValid).toBe(true); - const result = await built.provider.closePosition({ - symbol: 'BTC', - size: '', - orderType: 'market', - currentPrice: 100000, + expect( + (await built.provider.cancelOrder(cancelParams(placed.orderId ?? ''))) + .success, + ).toBe(false); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject({ + status: 'termination_pending', + error: expect.stringContaining( + mode === 'repeat' ? 'pagination is incomplete' : 'bounded collector', + ) as string, }); - expect(result.error).toBeUndefined(); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + await built.provider.disconnect(); + }, + ); + it.each(['owner', 'order', 'client', 'market', 'reread'] as const)( + 'refuses a changed %s snapshot without settling or replacing', + async (mode) => { + const built = setup(); + const placed = await built.provider.placeOrder(intent); + const exact = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!exact) { + throw new Error('Missing exact lookup'); + } + let reads = 0; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + reads += 1; + const result = (await exact(...args)) as { + code: number; + orders: LighterApiOrder[]; + }; + return { + ...result, + orders: result.orders.map((row) => { + switch (mode) { + case 'owner': + return { ...row, ownerAccountIndex: 29 }; + case 'order': + return { ...row, orderIndex: 9002, orderId: '9002' }; + case 'client': + return { + ...row, + clientOrderIndex: 101, + clientOrderId: '101', + }; + case 'market': + return { ...row, marketIndex: 2 }; + default: + return { + ...row, + timestamp: row.timestamp + Number(reads % 2 === 0), + }; + } + }), + }; + }, + ); + expect( + (await built.provider.cancelOrder(cancelParams(placed.orderId ?? ''))) + .success, + ).toBe(false); + expect((await built.provider.getNativeChaseRecords())[0].status).toBe( + 'termination_pending', + ); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ), + ).toHaveLength(1); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(mode === 'reread' ? 1 : 0); + await built.provider.disconnect(); + }, + ); + it.each(['sign', 'dispatch'] as const)( + 'fences exact Chase cancellation against signer session movement at %s', + async (phase) => { + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + const placed = await built.provider.placeOrder(intent); + const pending = createDeferred(); + const entered = createDeferred(); + if (phase === 'sign') { + const executeSpy = jest.spyOn(built.bridge, 'execute'); + const execute = executeSpy.getMockImplementation(); + if (!execute) { + throw new Error('Missing signer implementation'); + } + jest.spyOn(built.bridge, 'execute').mockImplementation(async (call) => { + const result = await execute(call); + if (call.function === '_signCancelOrder') { + entered.resolve(); + await pending.promise; + } + return result; + }); + } else { + const writeSpy = jest.spyOn(deps.diskCache, 'setItem'); + const write = writeSpy.getMockImplementation(); + if (!write) { + throw new Error('Missing disk implementation'); + } + jest + .spyOn(deps.diskCache, 'setItem') + .mockImplementation(async (key, value) => { + await write(key, value); + if ( + key.startsWith('lighterChase:') && + value.includes('"cancellations":[{"phase":"attempted"') + ) { + entered.resolve(); + await pending.promise; + } + }); + } + try { + const canceling = built.provider.cancelOrder( + cancelParams(placed.orderId ?? ''), + ); + await entered.promise; + built.fireReset(); + pending.resolve(); + expect((await canceling).success).toBe(false); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(0); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CREATE_ORDER, + ), + ).toHaveLength(1); + } finally { + pending.resolve(); + await built.provider.disconnect(); + } + }, + ); + it.each(['initial', 'tick'] as const)( + 'uses the absolute deadline with delayed %s observation', + async (phase) => { + jest.useFakeTimers(); + const built = setup(); + const exact = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!exact) { + throw new Error('Missing exact lookup'); + } + const started = Date.now(); + let delayed = false; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + if ( + !delayed && + (phase === 'initial' || Date.now() >= started + 120000) + ) { + delayed = true; + jest.setSystemTime( + started + (phase === 'initial' ? 150000 : 181000), + ); + } + return await exact(...args); + }, + ); + try { + const result = await built.provider.placeOrder({ + ...intent, + chaseIntervalMs: 120000, + chaseMaxDurationMs: 180000, + chaseMaxRepricings: 5, + }); + expect(result.success).toBe(true); + await jest.advanceTimersByTimeAsync( + phase === 'initial' ? 30000 : 120000, + ); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(1); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject( + { status: 'canceled', stopReason: 'duration_reached' }, + ); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + it('prepares Chase reference and duration after slow signer readiness', async () => { + jest.useFakeTimers(); + const built = setup(); + const create = jest + .spyOn(built.bridge, 'createClient') + .getMockImplementation(); + if (!create) { + throw new Error('Missing client creation'); + } + const readyAt = Date.now() + 70000; + jest + .spyOn(built.bridge, 'createClient') + .mockImplementation(async (params) => { + jest.setSystemTime(readyAt); + return await create(params); + }); + try { + const result = await built.provider.placeOrder(intent); expect(result.success).toBe(true); + expect( + (await built.provider.getNativeChaseRecords())[0].intent.startedAt, + ).toBe(readyAt); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }); + it('keeps original-slot Chase cleanup pending when only another signing slot is available', async () => { + const deps = diskDependencies(); + const original = setup({ platformDependencies: deps }); + const placed = await original.provider.placeOrder(intent); + const exact = + original.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!exact) { + throw new Error('Missing exact lookup'); + } + await original.provider.disconnect(); + const moved = setup({ platformDependencies: deps, apiKeyIndex: 2 }); + moved.clientInstance.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [ + { + accountIndex: 28, + apiKeyIndex: 2, + nonce: 1, + publicKey: '9c'.repeat(40), + }, + ], }); + moved.clientInstance.getOrdersByClientIds.mockImplementation(exact); + try { + expect( + (await moved.provider.cancelOrder(cancelParams(placed.orderId ?? ''))) + .success, + ).toBe(false); + expect((await moved.provider.getNativeChaseRecords())[0]).toMatchObject({ + intent: { owner: { apiKeyIndex: 7 } }, + status: 'termination_pending', + }); + expect( + moved.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + expect(moved.clientInstance.sendTx).not.toHaveBeenCalled(); + } finally { + await moved.provider.disconnect(); + } }); - - 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, + it.each(['active', 'history'] as const)( + 'keeps expired TWAP exclusion across restart and both strategies after incomplete %s reads', + async (failure) => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], }); - 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 exact = + built.clientInstance.getOrdersByClientIds.getMockImplementation() as + | ExactLookup + | undefined; + if (!exact) { + throw new Error('Missing exact lookup'); + } + built.clientInstance.getOrdersByClientIds.mockImplementation( + async (...args: Parameters) => { + const result = await exact(...args); + return { + ...result, + orders: result.orders.map((row) => ({ + ...row, + type: 'twap', + timeInForce: 'good-till-time', + status: 'in-progress', + })), + }; }, ); - 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({ + const twap: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'twap', + size: '0.00020', + leverage: 1, + twapDuration: 1, + }; + try { + await built.provider.placeOrder(twap); + const [record] = await built.provider.getNativeTwapObservations(); + jest.setSystemTime((record.record.placement.expiresAt ?? 0) + 30001); + built.clientInstance.getTx.mockResolvedValue(null); + if (failure === 'active') { + built.clientInstance.getActiveOrders.mockRejectedValue( + new Error('active read unavailable'), + ); + } else { + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + nextCursor: 'incomplete', + }); + } + const [uncertain] = await built.provider.getNativeTwapObservations(); + expect(uncertain.record.placement.phase).not.toBe('failed'); + await built.provider.disconnect(); + const restarted = setup({ platformDependencies: deps }); + restarted.clientInstance.getOrderBookDetails.mockResolvedValue({ + code: 200, + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], + }); + const chase = await restarted.provider.placeOrder(intent); + const another = await restarted.provider.placeOrder(twap); + expect(chase).toMatchObject({ + success: false, + error: expect.stringContaining( + 'native probe remains unresolved', + ) as string, + }); + expect(another).toMatchObject({ + success: false, + error: expect.stringContaining('unresolved') as string, + }); + expect( + restarted.calls.filter( + (call) => call.function === '_signCreateOrder', + ), + ).toHaveLength(0); + await restarted.provider.disconnect(); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } + }, + ); + + it.each(['sign', 'dispatch'] as const)( + 'fences TWAP cancellation with unavailable history across original-session %s movement', + async (phase) => { + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + built.clientInstance.getOrderBookDetails.mockResolvedValue({ code: 200, - l1Address: ACCOUNT.l1Address, - subAccounts: [ACCOUNT], + orderBookDetails: [ + { + ...BTC_MARKET, + lastTradePrice: 90000, + defaultInitialMarginFraction: 100, + minInitialMarginFraction: 100, + maintenanceMarginFraction: 50, + }, + ], }); - 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, + const parentRows = (): LighterApiOrder[] => { + const create = built.calls.find( + (call) => call.function === '_signCreateOrder', + ); + if (!create) { + return []; + } + return [ + { + orderIndex: 9001, + orderId: '9001', + clientOrderIndex: Number(create.params[2]), + clientOrderId: String(create.params[2]), + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.00020', + remainingBaseAmount: '0.00020', + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: String(Number(create.params[4]) / 10), + isAsk: false, + type: 'twap', + timeInForce: 'good-till-time', + reduceOnly: false, + status: 'in-progress', + orderExpiry: Number(create.params[10]), + timestamp: Math.floor((Number(create.params[10]) - 60000) / 1000), + nonce: Number(create.params[11]), + }, + ]; + }; + built.clientInstance.getOrdersByClientIds.mockImplementation( + async () => ({ code: 200, orders: parentRows() }), ); - }); - }); - 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), + built.clientInstance.getActiveOrders.mockImplementation(async () => ({ + code: 200, + orders: parentRows(), + })); + built.clientInstance.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], }); - setupTriggerVenue(built.clientInstance, built.bridge); + built.clientInstance.getTx.mockImplementation(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 7, + nonce: hash.startsWith('aaaa') ? 42 : 43, + status: 2, + })); const placed = await built.provider.placeOrder({ symbol: 'BTC', isBuy: true, - size: '0.001', - orderType: 'limit', - price: '90000', + orderType: 'twap', + size: '0.00020', + leverage: 1, + twapDuration: 1, + }); + expect(placed).toMatchObject({ success: true, orderId: '9001' }); + built.clientInstance.getInactiveOrders.mockRejectedValue( + new Error('history unavailable'), + ); + const pending = createDeferred(); + const entered = createDeferred(); + if (phase === 'sign') { + const executeSpy = jest.spyOn(built.bridge, 'execute'); + const execute = executeSpy.getMockImplementation(); + if (!execute) { + throw new Error('Missing signer implementation'); + } + executeSpy.mockImplementation(async (call) => { + const result = await execute(call); + if (call.function === '_signCancelOrder') { + entered.resolve(); + await pending.promise; + } + return result; + }); + } else { + const writeSpy = jest.spyOn(deps.diskCache, 'setItem'); + const write = writeSpy.getMockImplementation(); + if (!write) { + throw new Error('Missing disk implementation'); + } + writeSpy.mockImplementation(async (key, value) => { + await write(key, value); + if ( + key.startsWith('lighterNativeTwap:') && + value.includes('"cancellations":[{"phase":"attempted"') + ) { + entered.resolve(); + await pending.promise; + } + }); + } + try { + const cancellation = built.provider.cancelOrder({ + symbol: 'BTC', + orderType: 'twap', + orderId: '9001', + }); + await entered.promise; + built.fireReset(); + pending.resolve(); + expect((await cancellation).success).toBe(false); + expect( + built.clientInstance.sendTx.mock.calls.filter( + ([kind]) => kind === LIGHTER_TX_TYPE_CANCEL_ORDER, + ), + ).toHaveLength(0); + const [observation] = await built.provider.getNativeTwapObservations(); + expect(observation.record.intent.owner.apiKeyIndex).toBe(7); + expect(observation.record.terminalConfirmed).toBe(false); + expect( + built.calls.filter((call) => call.function === '_signCreateOrder'), + ).toHaveLength(1); + } finally { + pending.resolve(); + await built.provider.disconnect(); + } + }, + ); + it('logs and persists a scheduler journal read failure without continuing', async () => { + jest.useFakeTimers(); + const deps = diskDependencies(); + const built = setup({ platformDependencies: deps }); + try { + expect(await built.provider.placeOrder(intent)).toMatchObject({ + success: true, + }); + const readSpy = jest.spyOn(deps.diskCache, 'getItem'); + const read = readSpy.getMockImplementation(); + if (!read) { + throw new Error('Missing disk read'); + } + let failNext = true; + readSpy.mockImplementation(async (key) => { + if (failNext && key.startsWith('lighterChase:')) { + failNext = false; + throw new Error('journal read failed'); + } + return await read(key); + }); + await jest.advanceTimersByTimeAsync(1000); + expect(jest.spyOn(deps.debugLogger, 'log')).toHaveBeenCalledWith( + '[LighterProvider] Chase tick failed', + expect.objectContaining({ error: 'journal read failed' }), + ); + expect((await built.provider.getNativeChaseRecords())[0]).toMatchObject({ + status: 'termination_pending', + error: 'journal read failed', }); - 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'), - ); - }); + expect( + built.calls.filter((call) => call.function === '_signCancelOrder'), + ).toHaveLength(0); + } finally { + await built.provider.disconnect(); + jest.useRealTimers(); + } }); }); + +describe('integrated advanced perpetual market boundary', () => { + it.each(['validate', 'place'] as const)( + 'refuses post-only active spot before account or signer setup during %s', + async (operation) => { + const built = buildProvider(); + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, marketType: 'spot' }, + ]); + const intent: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'limit', + timeInForce: 'ALO', + size: '0.001', + price: '100000', + }; + const result = + operation === 'validate' + ? await built.provider.validateOrder(intent) + : await built.provider.placeOrder(intent); + expect(result.error).toContain('active perpetual market'); + expect(built.clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + expect(built.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); + it.each(['twap', 'chase'] as const)( + 'refuses %s probe on active spot before account or signer setup', + async (orderType) => { + const built = buildProvider({ + nativeTwapTestnetProbe: true, + chaseTestnetProbe: true, + }); + built.clientInstance.getOrderBooks.mockResolvedValue([ + { ...BTC_MARKET, marketType: 'spot' }, + ]); + const result = await built.provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + orderType, + size: '0.0002', + leverage: 1, + ...(orderType === 'twap' + ? { twapDuration: 1 } + : { + chaseIntervalMs: 1000, + chaseMaxDurationMs: 60000, + chaseMaxRepricings: 1, + chaseMaxDistanceBps: 100, + }), + }); + expect(result.error).toContain('active perpetual market'); + expect(built.clientInstance.getAccountByIndex).not.toHaveBeenCalled(); + expect(built.calls).toHaveLength(0); + expect(built.clientInstance.sendTx).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/services/LighterChaseService.reconciliation.test.ts b/packages/perps-controller/tests/src/services/LighterChaseService.reconciliation.test.ts new file mode 100644 index 00000000000..1fedea840e1 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterChaseService.reconciliation.test.ts @@ -0,0 +1,296 @@ +import { LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS } from '../../../src/constants/lighterConfig.js'; +import { LighterChaseService } from '../../../src/services/LighterChaseService.js'; +import type { + LighterChaseIo, + LighterChaseRecord, +} from '../../../src/services/LighterChaseService.js'; + +const owner = { + wallet: '0xabc', + network: 'testnet' as const, + accountIndex: 28, + apiKeyIndex: 7, +}; +const handle = 'lighter-chase:100'; +const cancellation = { nonce: 209, txHash: 'b'.repeat(64), expiresAt: 110000 }; +const retained = (): LighterChaseRecord => ({ + intent: { + owner, + handle, + symbol: 'BTC', + marketId: 1, + isBuy: true, + reduceOnly: false, + originalSize: '0.0002', + arrivalPrice: '100000', + sizeDecimals: 5, + priceDecimals: 1, + startedAt: 100000, + intervalMs: 1000, + maxDurationMs: 60000, + maxRepricings: 2, + maxDistanceBps: 100, + maxNotional: '20', + minBaseAmount: '0.00001', + minQuoteAmount: '1', + }, + status: 'termination_pending', + stopReason: 'canceled', + repricings: 0, + lastTickAt: 100000, + executedSize: '0', + executedNotional: '0', + children: [ + { + clientOrderId: '101', + size: '0.0002', + price: '100000', + quotedAt: 100000, + placement: { + phase: 'acknowledged', + nonce: 208, + txHash: 'a'.repeat(64), + expiresAt: 110000, + acknowledged: true, + }, + cancellations: [ + { phase: 'acknowledged', ...cancellation, acknowledged: true }, + ], + observation: { + orderId: '90101', + terminal: false, + filledSize: '0', + filledNotional: '0', + remainingSize: '0.0002', + }, + }, + ], +}); +type ReconciliationFixture = { + service: LighterChaseService; + io: LighterChaseIo; + storage: jest.Mocked<{ + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; + }>; + disk: Map; + key: string; + reconcile: () => Promise; + setTerminal: () => void; +}; +const setup = (record = retained()): ReconciliationFixture => { + const key = `lighterChase:${JSON.stringify([owner.network, owner.wallet, owner.accountIndex])}`; + const disk = new Map([ + [key, JSON.stringify({ version: 1, records: [record] })], + ]); + const storage = { + getItem: jest.fn(async (name: string) => disk.get(name) ?? null), + setItem: jest.fn(async (name: string, value: string) => { + disk.set(name, value); + }), + }; + let terminal = false; + const io: LighterChaseIo = { + assertCurrent: jest.fn(), + now: () => Date.now(), + allocateClientId: jest.fn(() => '102'), + quote: jest.fn(async () => '100000'), + place: jest.fn(), + cancel: jest.fn(async (_child, hooks) => { + await hooks.signed({ + nonce: 210, + txHash: 'c'.repeat(64), + expiresAt: 110000, + }); + await hooks.beforeDispatch(); + hooks.accepted(); + await hooks.afterAccepted(); + terminal = true; + }), + observe: jest.fn(async () => ({ + orderId: '90101', + terminal, + filledSize: '0', + filledNotional: '0', + remainingSize: '0.0002', + })), + }; + const service = new LighterChaseService({ storage }); + const reconcile = async (): Promise => { + const pending = service + .reconcileCancellation(owner, handle, '101', cancellation, io) + .then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + await jest.advanceTimersByTimeAsync( + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS + 1, + ); + const result = await pending; + if ('error' in result) { + throw result.error; + } + return result.value; + }; + return { + service, + io, + storage, + disk, + key, + reconcile, + setTerminal: (): void => { + terminal = true; + }, + }; +}; + +describe('Lighter Chase cancellation reconciliation without dispatch', () => { + beforeEach(() => jest.useFakeTimers({ now: 100000 })); + afterEach(() => jest.useRealTimers()); + + it('settles exact terminal evidence without changing the cancellation inventory', async () => { + const env = setup(); + env.setTerminal(); + const result = await env.reconcile(); + expect(result.status).toBe('canceled'); + expect(result.children[0].cancellations).toStrictEqual( + retained().children[0].cancellations, + ); + expect(env.io.cancel).not.toHaveBeenCalled(); + expect(env.io.place).not.toHaveBeenCalled(); + expect(env.io.allocateClientId).not.toHaveBeenCalled(); + }); + it('never sends another cancellation after acknowledged phase downgrades with a nonterminal child', async () => { + const env = setup(); + jest.mocked(env.io.observe).mockImplementation(async (child) => { + child.cancellations[0].phase = 'failed'; + return { + orderId: '90101', + filledSize: '0', + filledNotional: '0', + remainingSize: '0.0002', + terminal: false, + }; + }); + const result = await env.reconcile(); + expect(result.status).toBe('termination_pending'); + expect(result.children[0].cancellations).toStrictEqual([ + { phase: 'failed', ...cancellation, acknowledged: true }, + ]); + expect(env.io.cancel).not.toHaveBeenCalled(); + expect(env.io.allocateClientId).not.toHaveBeenCalled(); + }); + it.each([undefined, false])( + 'keeps an attempted cancellation with acknowledgement %s unresolved on a nonterminal snapshot', + async (acknowledged) => { + const record = retained(); + Object.assign(record.children[0].cancellations[0], { + phase: 'attempted', + acknowledged, + }); + const env = setup(record); + expect((await env.reconcile()).status).toBe('termination_pending'); + expect(env.io.cancel).not.toHaveBeenCalled(); + }, + ); + it('retains unresolved management when a reconciliation reply is lost', async () => { + const env = setup(); + jest + .mocked(env.io.observe) + .mockRejectedValue(new Error('read response lost')); + const result = await env.reconcile(); + expect(result.status).toBe('termination_pending'); + expect(result.children[0].cancellations).toStrictEqual( + retained().children[0].cancellations, + ); + expect(env.io.cancel).not.toHaveBeenCalled(); + }); + it.each([ + 'missing record', + 'missing cancellation', + 'prepared cancellation', + 'malformed inventory', + 'active chase', + ] as const)('rejects %s before observation or dispatch', async (failure) => { + const record = retained(); + if (failure === 'missing cancellation') { + record.children[0].cancellations = []; + } + if (failure === 'prepared cancellation') { + record.children[0].cancellations = [{ phase: 'prepared' }]; + } + if (failure === 'active chase') { + record.status = 'active'; + delete record.stopReason; + } + const env = setup(record); + if (failure === 'missing record') { + env.disk.set(env.key, JSON.stringify({ version: 1, records: [] })); + } + if (failure === 'malformed inventory') { + env.disk.set(env.key, '{'); + } + await expect(env.reconcile()).rejects.toThrow( + /JSON|Chase|dispatch|Expected/u, + ); + expect(env.io.observe).not.toHaveBeenCalled(); + expect(env.io.cancel).not.toHaveBeenCalled(); + expect(env.storage.setItem).not.toHaveBeenCalled(); + }); + it.each(['owner', 'child', 'nonce', 'hash', 'expiry'] as const)( + 'rejects stale exact %s identity without dispatch', + async (field) => { + const env = setup(); + const pending = env.service.reconcileCancellation( + field === 'owner' ? { ...owner, apiKeyIndex: 8 } : owner, + handle, + field === 'child' ? '102' : '101', + { + ...cancellation, + ...(field === 'nonce' ? { nonce: 210 } : {}), + ...(field === 'hash' ? { txHash: 'c'.repeat(64) } : {}), + ...(field === 'expiry' ? { expiresAt: 110001 } : {}), + }, + env.io, + ); + await expect(pending).rejects.toThrow(/JSON|Chase|dispatch|Expected/u); + expect(env.io.observe).not.toHaveBeenCalled(); + expect(env.io.cancel).not.toHaveBeenCalled(); + }, + ); + it('fences an ownership switch while reading the exact cancellation', async () => { + const env = setup(); + jest.mocked(env.io.observe).mockImplementation(async () => { + jest.mocked(env.io.assertCurrent).mockImplementation(() => { + throw new Error('owner switched'); + }); + return { + orderId: '90101', + filledSize: '0', + filledNotional: '0', + remainingSize: '0.0002', + terminal: true, + }; + }); + await expect(env.reconcile()).rejects.toThrow('owner switched'); + expect(env.io.cancel).not.toHaveBeenCalled(); + const saved = JSON.parse(env.disk.get(env.key) ?? '') as { + records: LighterChaseRecord[]; + }; + expect(saved.records[0].status).toBe('termination_pending'); + }); + it('preserves ordinary explicit cancellation behavior after exact failure', async () => { + const record = retained(); + record.children[0].cancellations[0].phase = 'failed'; + const env = setup(record); + const pending = env.service.stop(owner, handle, env.io, 'canceled'); + await jest.advanceTimersByTimeAsync( + LIGHTER_CHASE_CANCEL_SETTLEMENT_WINDOW_MS + 1, + ); + const result = await pending; + expect(result.status).toBe('canceled'); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(result.children[0].cancellations).toHaveLength(2); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterChaseService.test.ts b/packages/perps-controller/tests/src/services/LighterChaseService.test.ts new file mode 100644 index 00000000000..5aaf16b8095 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterChaseService.test.ts @@ -0,0 +1,1254 @@ +import { + LighterChaseService, + LighterChaseObservationPendingError, +} from '../../../src/services/LighterChaseService.js'; +import type { + LighterChaseIntent, + LighterChaseIo, + LighterChaseChild, + LighterChaseDispatchHooks, + LighterChaseRecord, +} from '../../../src/services/LighterChaseService.js'; +import type { LighterChaseChildObservation } from '../../../src/utils/lighterChase.js'; +import { createDeferred } from '../../helpers/serviceMocks.js'; + +const owner = { + wallet: '0xabc', + network: 'testnet' as const, + accountIndex: 28, + apiKeyIndex: 7, +}; +const intent: LighterChaseIntent = { + owner, + handle: 'lighter-chase:100', + symbol: 'BTC', + marketId: 1, + isBuy: true, + reduceOnly: false, + originalSize: '0.0002', + arrivalPrice: '100000', + sizeDecimals: 5, + priceDecimals: 1, + startedAt: 100000, + intervalMs: 1000, + maxDurationMs: 60000, + maxRepricings: 2, + maxDistanceBps: 100, + maxNotional: '20', + minBaseAmount: '0.00001', + minQuoteAmount: '1', +}; + +type TestEnvironment = { + service: LighterChaseService; + io: LighterChaseIo; + storage: { + getItem: jest.Mock, [string]>; + setItem: jest.Mock, [string, string]>; + }; + disk: Map; + observed: Map; + setNow: (value: number) => void; + setQuote: (value: string) => void; +}; +const setup = (): TestEnvironment => { + let now = 100000; + let nextId = 100; + let quote = '100000'; + const disk = new Map(); + const storage = { + getItem: jest.fn(async (key: string) => disk.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + disk.set(key, value); + }), + }; + const observed = new Map< + string, + { + orderId: string; + terminal: boolean; + filledSize: string; + filledNotional: string; + remainingSize: string; + } + >(); + const sign = async (hooks: LighterChaseDispatchHooks): Promise => { + await hooks.signed({ + nonce: nextId, + txHash: nextId.toString(16).padStart(8, '0'), + expiresAt: now + 10000, + }); + await hooks.beforeDispatch(); + }; + const io: LighterChaseIo = { + assertCurrent: jest.fn(), + now: () => now, + allocateClientId: () => { + nextId += 1; + return String(nextId); + }, + quote: jest.fn(async () => quote), + place: jest.fn(async (child, hooks) => { + await sign(hooks); + observed.set(child.clientOrderId, { + orderId: `9${child.clientOrderId}`, + terminal: false, + filledSize: '0', + filledNotional: '0', + remainingSize: child.size, + }); + }), + cancel: jest.fn(async (child, hooks) => { + await sign(hooks); + const row = observed.get(child.clientOrderId); + if (!row) { + throw new Error('unknown'); + } + observed.set(child.clientOrderId, { ...row, terminal: true }); + }), + observe: jest.fn(async (child) => { + const row = observed.get(child.clientOrderId); + if (!row) { + throw new Error('child visibility uncertain'); + } + return { ...row }; + }), + }; + const service = new LighterChaseService({ storage }); + return { + service, + io, + storage, + disk, + observed, + setNow: (value: number): void => { + now = value; + }, + setQuote: (value: string): void => { + quote = value; + }, + }; +}; + +describe('Lighter bounded Chase lifecycle', () => { + it.each([undefined, false, true])( + 'reads version-1 acknowledgment evidence %s without replay', + async (acknowledged) => { + const env = setup(); + const record = await env.service.start(intent, env.io); + record.status = 'termination_pending'; + const dispatch = record.children[0].placement; + dispatch.phase = acknowledged === true ? 'acknowledged' : 'attempted'; + if (acknowledged === undefined) { + delete dispatch.acknowledged; + } else { + dispatch.acknowledged = acknowledged; + } + const key = [...env.disk.keys()][0]; + env.disk.set(key, JSON.stringify({ version: 1, records: [record] })); + const restored = new LighterChaseService({ storage: env.storage }); + const records = await restored.list(owner, env.io); + expect(records[0].children[0].placement).toStrictEqual(dispatch); + await restored.tick(owner, intent.handle, env.io); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).not.toHaveBeenCalled(); + }, + ); + it.each([ + { phase: 'attempted', acknowledged: 'false' }, + { phase: 'acknowledged', acknowledged: false }, + { phase: 'attempted', acknowledged: true }, + ])('rejects malformed acknowledgment evidence %s', async (evidence) => { + const env = setup(); + const record = await env.service.start(intent, env.io); + Object.assign(record.children[0].placement, evidence); + const key = [...env.disk.keys()][0]; + env.disk.set(key, JSON.stringify({ version: 1, records: [record] })); + const restored = new LighterChaseService({ storage: env.storage }); + await expect(restored.list(owner, env.io)).rejects.toThrow( + /acknowledged|acceptance/u, + ); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).not.toHaveBeenCalled(); + }); + it.each([ + 'owner', + 'clientOrderId', + 'size', + 'price', + 'nonce', + 'txHash', + 'expiresAt', + ] as const)( + 'refuses captured completion after saved %s changes', + async (field) => { + const env = setup(); + let completionError: unknown; + let savedAfterCompletion: string | undefined; + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 8, txHash: 'deadbeef', expiresAt: 110000 }); + await hooks.beforeDispatch(); + const key = [...env.disk.keys()][0]; + const journal = JSON.parse(env.disk.get(key) ?? '') as { + records: LighterChaseRecord[]; + }; + const record = journal.records[0]; + const child = record.children[0]; + if (field === 'owner') { + record.intent.owner.apiKeyIndex += 1; + } else if (field === 'clientOrderId') { + child.clientOrderId = '102'; + } else if (field === 'size') { + child.size = '0.0001'; + } else if (field === 'price') { + child.price = '99999'; + } else if (field === 'nonce') { + child.placement.nonce = 9; + } else if (field === 'txHash') { + child.placement.txHash = 'abcdefab'; + } else { + child.placement.expiresAt = 110001; + } + env.disk.set(key, JSON.stringify(journal)); + hooks.accepted(); + completionError = await hooks + .afterAccepted() + .catch((error: unknown) => error); + savedAfterCompletion = env.disk.get(key); + throw new Error('completion attempt mismatch'); + }); + await env.service.start(intent, env.io); + expect(completionError).toBeInstanceOf(Error); + expect((completionError as Error).message).toBe( + 'Lighter Chase completion attempt mismatch', + ); + expect(savedAfterCompletion).not.toContain('"acknowledged":true'); + const expected = [ + { + intent, + status: 'termination_pending', + children: [ + { + clientOrderId: '101', + size: '0.0002', + price: '100000', + placement: { + phase: 'acknowledged', + acknowledged: true, + nonce: 8, + txHash: 'deadbeef', + expiresAt: 110000, + }, + }, + ], + }, + ]; + expect(await env.service.list(owner, env.io)).toMatchObject(expected); + const restarted = new LighterChaseService({ storage: env.storage }); + expect(await restarted.list(owner, env.io)).toMatchObject(expected); + await restarted.tick(owner, intent.handle, env.io); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).not.toHaveBeenCalled(); + }, + ); + it.each( + (['place', 'cancel'] as const).flatMap((operation) => + (['read', 'parse'] as const).map((failure) => ({ operation, failure })), + ), + )( + 'keeps accepted $operation readable after completion $failure failure and restart', + async ({ operation, failure }) => { + jest.useFakeTimers(); + jest.setSystemTime(100000); + try { + const env = setup(); + env.io.now = (): number => Date.now(); + const send: LighterChaseIo['place'] = async (child, hooks) => { + await hooks.signed({ + nonce: 8, + txHash: 'deadbeef', + expiresAt: 110000, + }); + await hooks.beforeDispatch(); + if (operation === 'place') { + env.observed.set(child.clientOrderId, { + orderId: `9${child.clientOrderId}`, + terminal: false, + filledSize: '0', + filledNotional: '0', + remainingSize: child.size, + }); + } + hooks.accepted(); + if (failure === 'read') { + env.storage.getItem.mockRejectedValueOnce( + new Error('completion read failed'), + ); + } else { + env.storage.getItem.mockResolvedValueOnce('{"version":1,'); + } + await hooks.afterAccepted(); + }; + if (operation === 'cancel') { + await env.service.start(intent, env.io); + jest.mocked(env.io.cancel).mockImplementation(send); + } else { + jest.mocked(env.io.place).mockImplementation(send); + } + const pending = + operation === 'place' + ? env.service.start(intent, env.io) + : env.service.stop(owner, intent.handle, env.io, 'canceled'); + await jest.advanceTimersByTimeAsync(10000); + expect((await pending).status).toBe('termination_pending'); + const expectedDispatch = { + phase: 'acknowledged', + acknowledged: true, + nonce: 8, + txHash: 'deadbeef', + expiresAt: 110000, + }; + for (const service of [ + env.service, + new LighterChaseService({ storage: env.storage }), + ]) { + const records = await service.list(owner, env.io); + expect(records[0].intent).toStrictEqual(intent); + expect(records[0].status).toBe('termination_pending'); + const child = records[0].children[0]; + expect( + operation === 'place' ? child.placement : child.cancellations[0], + ).toMatchObject(expectedDispatch); + await service.tick(owner, intent.handle, env.io); + } + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).toHaveBeenCalledTimes( + operation === 'cancel' ? 1 : 0, + ); + } finally { + jest.useRealTimers(); + } + }, + ); + describe('bounded Chase cancellation settlement', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(100000); + }); + afterEach(() => jest.useRealTimers()); + + const pendingCancel = async (env: TestEnvironment): Promise => { + env.io.now = (): number => Date.now(); + await env.service.start(intent, env.io); + jest.mocked(env.io.cancel).mockImplementation(async (_child, hooks) => { + await hooks.signed({ + nonce: 202, + txHash: 'cdefcdef', + expiresAt: 120000, + }); + await hooks.beforeDispatch(); + }); + }; + const terminal = ( + env: TestEnvironment, + filledSize = '0', + filledNotional = '0', + ): void => { + const row = env.observed.get('101'); + if (!row) { + throw new Error('Missing owned child'); + } + env.observed.set('101', { + ...row, + terminal: true, + filledSize, + filledNotional, + remainingSize: filledSize === '0' ? intent.originalSize : '0.00015', + }); + }; + + it.each(['live', 'missing'] as const)( + 'settles delayed %s visibility after exactly one cancellation', + async (visibility) => { + const env = setup(); + await pendingCancel(env); + const observe = jest.mocked(env.io.observe).getMockImplementation(); + if (!observe) { + throw new Error('Missing observation implementation'); + } + jest + .mocked(env.io.observe) + .mockImplementation(async (child, recordEvidence) => { + if ( + visibility === 'missing' && + child.cancellations.length > 0 && + !env.observed.get('101')?.terminal + ) { + throw new LighterChaseObservationPendingError( + 'Exact child pending', + ); + } + return await observe(child, recordEvidence); + }); + const stopping = env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + setTimeout(() => terminal(env), 2000); + await jest.advanceTimersByTimeAsync(2500); + const result = await stopping; + expect(result.status).toBe('canceled'); + expect(result.children[0].cancellations).toStrictEqual([ + expect.objectContaining({ phase: 'acknowledged', nonce: 202 }), + ]); + expect(result.children[0].observation?.terminal).toBe(true); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect((await env.service.list(owner, env.io))[0].status).toBe( + 'canceled', + ); + }, + ); + + it.each(['live', 'missing'] as const)( + 'bounds %s visibility and reconciles the retained attempt without replay', + async (visibility) => { + const env = setup(); + await pendingCancel(env); + if (visibility === 'missing') { + const observe = jest.mocked(env.io.observe).getMockImplementation(); + if (!observe) { + throw new Error('Missing observation implementation'); + } + jest + .mocked(env.io.observe) + .mockImplementation(async (child, recordEvidence) => { + if ( + child.cancellations.length > 0 && + !env.observed.get('101')?.terminal + ) { + throw new LighterChaseObservationPendingError( + 'Exact child pending', + ); + } + return await observe(child, recordEvidence); + }); + } + let finished = false; + const stopping = env.service + .stop(owner, intent.handle, env.io, 'canceled') + .then((result) => { + finished = true; + return result; + }); + await jest.advanceTimersByTimeAsync(9999); + expect(finished).toBe(false); + await jest.advanceTimersByTimeAsync(1); + expect((await stopping).status).toBe('termination_pending'); + const retrying = env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + setTimeout(() => terminal(env), 2000); + await jest.advanceTimersByTimeAsync(2500); + expect((await retrying).status).toBe('canceled'); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(env.io.place).toHaveBeenCalledTimes(1); + }, + ); + + it('settles delayed terminal evidence after cancellation response loss without replay', async () => { + const env = setup(); + await pendingCancel(env); + const cancel = jest.mocked(env.io.cancel).getMockImplementation(); + if (!cancel) { + throw new Error('Missing cancellation implementation'); + } + jest.mocked(env.io.cancel).mockImplementation(async (child, hooks) => { + await cancel(child, hooks); + throw new Error('cancel response lost'); + }); + const stopping = env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + setTimeout(() => terminal(env), 2000); + await jest.advanceTimersByTimeAsync(2500); + const result = await stopping; + expect(result.status).toBe('canceled'); + expect(result.children[0].cancellations).toStrictEqual([ + expect.objectContaining({ phase: 'attempted', nonce: 202 }), + ]); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + + it.each(['deadline', 'backwards'] as const)( + 'uses the injected cancellation clock for a %s change', + async (change) => { + const env = setup(); + await pendingCancel(env); + let injectedNow = 100000; + env.io.now = (): number => injectedNow; + let finished = false; + const stopping = env.service + .stop(owner, intent.handle, env.io, 'canceled') + .then((result) => { + finished = true; + return result; + }); + await jest.advanceTimersByTimeAsync(0); + expect(finished).toBe(false); + injectedNow = change === 'deadline' ? 110000 : 99999; + await jest.advanceTimersByTimeAsync(250); + expect(finished).toBe(true); + expect((await stopping).status).toBe('termination_pending'); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(env.io.place).toHaveBeenCalledTimes(1); + }, + ); + + it('persists partial fills during settlement and retains them in the terminal receipt', async () => { + const env = setup(); + await pendingCancel(env); + const stopping = env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + setTimeout(() => { + terminal(env, '0.00005', '5'); + const row = env.observed.get('101'); + if (row) { + env.observed.set('101', { ...row, terminal: false }); + } + }, 1000); + setTimeout(() => terminal(env, '0.00005', '5'), 2000); + await jest.advanceTimersByTimeAsync(1250); + expect([...env.disk.values()].join(' ')).toContain( + '"executedSize":"0.00005"', + ); + await jest.advanceTimersByTimeAsync(1000); + expect(await stopping).toMatchObject({ + status: 'canceled', + executedSize: '0.00005', + executedNotional: '5', + }); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }); + + it('fences owner changes during the read-only wait before further reads or journal writes', async () => { + const env = setup(); + await pendingCancel(env); + const stopping = env.service + .stop(owner, intent.handle, env.io, 'canceled') + .catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(1000); + const disk = [...env.disk.entries()]; + const reads = jest.mocked(env.io.observe).mock.calls.length; + jest.mocked(env.io.assertCurrent).mockImplementation(() => { + throw new Error('owner changed'); + }); + await jest.advanceTimersByTimeAsync(250); + expect(await stopping).toMatchObject({ message: 'owner changed' }); + expect([...env.disk.entries()]).toStrictEqual(disk); + expect(env.io.observe).toHaveBeenCalledTimes(reads); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }); + + it('retains foreign identity failures instead of retrying them as visibility lag', async () => { + const env = setup(); + await pendingCancel(env); + const observe = jest.mocked(env.io.observe).getMockImplementation(); + if (!observe) { + throw new Error('Missing observation implementation'); + } + jest + .mocked(env.io.observe) + .mockImplementation(async (child, recordEvidence) => { + if (child.cancellations.length > 0) { + throw new Error('foreign child identity'); + } + return await observe(child, recordEvidence); + }); + const result = await env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + expect(result).toMatchObject({ + status: 'termination_pending', + error: 'foreign child identity', + }); + const reads = jest.mocked(env.io.observe).mock.calls.length; + await jest.advanceTimersByTimeAsync(10000); + expect(env.io.observe).toHaveBeenCalledTimes(reads); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }); + }); + + describe('prepared final quote', () => { + it('persists a fresh bounded price after slow preparation without changing the original intent', async () => { + const env = setup(); + const initial = structuredClone(intent); + const place = jest.mocked(env.io.place).getMockImplementation(); + if (!place) { + throw new Error('Missing placement implementation'); + } + jest.mocked(env.io.place).mockImplementation(async (child, hooks) => { + const { clientOrderId } = child; + env.setNow(106000); + await hooks.prepareQuote('99999.9'); + expect(child).toMatchObject({ + clientOrderId, + price: '99999.9', + size: intent.originalSize, + quotedAt: 106000, + placement: { phase: 'prepared' }, + }); + expect([...env.disk.values()].join(' ')).toContain('"price":"99999.9"'); + await place(child, hooks); + }); + const result = await env.service.start(intent, env.io); + expect(result.status).toBe('active'); + expect(result.intent).toStrictEqual(initial); + expect(result.children).toHaveLength(1); + expect(result.repricings).toBe(0); + }); + it.each(['signed', 'attempted', 'acknowledged'] as const)( + 'refuses final quote mutation of a %s child', + async (phase) => { + const env = setup(); + let retained: LighterChaseDispatchHooks | undefined; + let refusal: unknown; + const mutate = async ( + hooks: LighterChaseDispatchHooks, + ): Promise => { + try { + await hooks.prepareQuote('99999.8'); + } catch (error) { + refusal = error; + } + }; + jest.mocked(env.io.place).mockImplementation(async (child, hooks) => { + retained = hooks; + await hooks.prepareQuote('99999.9'); + await hooks.signed({ + nonce: 8, + txHash: 'abcdabcd', + expiresAt: 110000, + }); + if (phase === 'signed') { + await mutate(hooks); + } + await hooks.beforeDispatch(); + if (phase === 'attempted') { + await mutate(hooks); + } + env.observed.set(child.clientOrderId, { + orderId: '9001', + terminal: false, + filledSize: '0', + filledNotional: '0', + remainingSize: child.size, + }); + }); + const result = await env.service.start(intent, env.io); + expect(result.status).toBe('active'); + if (!retained) { + throw new Error('Missing retained hooks'); + } + if (phase === 'acknowledged') { + await mutate(retained); + } + if (!(refusal instanceof Error)) { + throw new Error('Expected a refused quote mutation'); + } + expect(refusal.message).toContain('cannot change after signing'); + expect(result.children[0]).toMatchObject({ + price: '99999.9', + quotedAt: 100000, + size: intent.originalSize, + placement: { phase: 'acknowledged' }, + }); + }, + ); + it.each([ + { now: 99999, error: 'clock moved backwards' }, + { now: 160000, error: 'duration elapsed' }, + ])('refuses a fresh quote with $error', async ({ now, error }) => { + const env = setup(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + env.setNow(now); + await hooks.prepareQuote('99999.9'); + }); + const result = await env.service.start(intent, env.io); + expect(result.status).toBe('failed'); + expect(result.error).toContain(error); + expect(env.observed.size).toBe(0); + expect(result.children[0].price).toBe('100000'); + }); + it.each(['failure', 'slow write'] as const)( + 'does not sign after final quote persistence %s', + async (condition) => { + const env = setup(); + let injected = false; + env.storage.setItem.mockImplementation(async (key, value) => { + if (!injected && value.includes('"price":"99999.9"')) { + injected = true; + if (condition === 'failure') { + throw new Error('final quote disk failure'); + } + env.setNow(106000); + } + env.disk.set(key, value); + }); + const signed = jest.fn(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.prepareQuote('99999.9'); + signed(); + await hooks.signed({ + nonce: 8, + txHash: 'abcdabcd', + expiresAt: 110000, + }); + }); + const result = await env.service.start(intent, env.io); + expect(result.status).toBe('failed'); + expect(result.error).toContain( + condition === 'failure' + ? 'final quote disk failure' + : 'quote is stale', + ); + expect(signed).not.toHaveBeenCalled(); + expect(env.observed.size).toBe(0); + }, + ); + }); + it('cancels the exact owned child despite unavailable fill reconciliation and retains pending termination', async () => { + const env = setup(); + await env.service.start(intent, env.io); + jest + .mocked(env.io.observe) + .mockRejectedValue(new Error('fill history unavailable')); + const result = await env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(result.status).toBe('termination_pending'); + expect(result.error).toContain('fill history unavailable'); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('accepts supported slot 2 for bounded Chase ownership', async () => { + const env = setup(); + expect( + ( + await env.service.start( + { ...intent, owner: { ...owner, apiKeyIndex: 2 } }, + env.io, + ) + ).status, + ).toBe('active'); + }); + it.each(['abcd', 'gggggggg'])( + 'rejects invalid exact signed hash %s before transport', + async (txHash) => { + const env = setup(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 8, txHash, expiresAt: 110000 }); + }); + const result = await env.service.start(intent, env.io); + expect(result.error).toBe('Invalid Lighter Chase signing identity'); + expect(result.children[0].placement.phase).toBe('failed'); + }, + ); + it('persists immutable child ownership before sign and attempt before transport', async () => { + const env = setup(); + const submitted: LighterChaseChild[] = []; + jest.mocked(env.io.place).mockImplementation(async (child, hooks) => { + expect([...env.disk.values()].join(' ')).toContain(child.clientOrderId); + await hooks.signed({ nonce: 8, txHash: 'abcdabcd', expiresAt: 110000 }); + expect([...env.disk.values()].join(' ')).toContain('signed'); + await hooks.beforeDispatch(); + expect([...env.disk.values()].join(' ')).toContain('attempted'); + submitted.push(child); + env.observed.set(child.clientOrderId, { + orderId: '9001', + terminal: false, + filledSize: '0', + filledNotional: '0', + remainingSize: child.size, + }); + }); + expect((await env.service.start(intent, env.io)).status).toBe('active'); + expect(submitted).toHaveLength(1); + }); + it('subtracts authoritative partial fills during cancellation before replacement', async () => { + const env = setup(); + await env.service.start(intent, env.io); + jest.mocked(env.io.cancel).mockImplementation(async (child, hooks) => { + await hooks.signed({ nonce: 9, txHash: 'cdefcdef', expiresAt: 111000 }); + await hooks.beforeDispatch(); + env.observed.set(child.clientOrderId, { + orderId: `9${child.clientOrderId}`, + terminal: true, + filledSize: '0.00005', + filledNotional: '5', + remainingSize: '0.00015', + }); + }); + env.setNow(101000); + env.setQuote('99999'); + const result = await env.service.tick(owner, intent.handle, env.io); + expect(result.children.map((child) => child.size)).toStrictEqual([ + '0.0002', + '0.00015', + ]); + expect(result.repricings).toBe(1); + expect(result.executedSize).toBe('0.00005'); + }); + it('never replaces an acknowledged cancel whose child remains live', async () => { + jest.useFakeTimers(); + try { + const env = setup(); + await env.service.start(intent, env.io); + jest.mocked(env.io.cancel).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 9, txHash: 'cdefcdef', expiresAt: 111000 }); + await hooks.beforeDispatch(); + }); + env.setNow(101000); + env.setQuote('99999'); + const ticking = env.service.tick(owner, intent.handle, env.io); + await jest.advanceTimersByTimeAsync(10000); + const result = await ticking; + expect(result.status).toBe('active'); + expect(env.io.place).toHaveBeenCalledTimes(1); + await env.service.tick(owner, intent.handle, env.io); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + it('preserves response loss across restart without creating or resuming a child', async () => { + const env = setup(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 8, txHash: 'deadbeef', expiresAt: 110000 }); + await hooks.beforeDispatch(); + throw new Error('response lost'); + }); + const result = await env.service.start(intent, env.io); + expect(result.status).toBe('termination_pending'); + const restarted = new LighterChaseService({ storage: env.storage }); + expect((await restarted.list(owner, env.io))[0].status).toBe( + 'termination_pending', + ); + await restarted.tick(owner, intent.handle, env.io); + expect(env.io.place).toHaveBeenCalledTimes(1); + await expect( + restarted.start({ ...intent, handle: 'different' }, env.io), + ).rejects.toThrow('unresolved'); + }); + it('blocks signing when immutable journal persistence fails', async () => { + const env = setup(); + env.storage.setItem.mockRejectedValue(new Error('disk unavailable')); + await expect(env.service.start(intent, env.io)).rejects.toThrow( + 'disk unavailable', + ); + expect(env.io.place).not.toHaveBeenCalled(); + }); + it('retains attempted ownership when the acknowledgment journal write fails', async () => { + const env = setup(); + env.storage.setItem.mockImplementation(async (key, value) => { + if (value.includes('acknowledged')) { + throw new Error('ack disk failure'); + } + env.disk.set(key, value); + }); + await expect(env.service.start(intent, env.io)).rejects.toThrow( + 'ack disk failure', + ); + expect([...env.disk.values()].join(' ')).toContain('attempted'); + const restarted = new LighterChaseService({ storage: env.storage }); + expect((await restarted.list(owner, env.io))[0].status).toBe( + 'termination_pending', + ); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('releases a signed child refused before transport so stop needs no cancel', async () => { + const env = setup(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 8, txHash: 'abcdabcd', expiresAt: 110000 }); + throw new Error('nonce ledger full'); + }); + expect(await env.service.start(intent, env.io)).toMatchObject({ + status: 'failed', + error: 'nonce ledger full', + }); + const records = await env.service.list(owner, env.io); + expect(records[0].children[0].placement.phase).toBe('failed'); + await env.service.stop(owner, intent.handle, env.io, 'canceled'); + expect(env.io.cancel).not.toHaveBeenCalled(); + }); + it('retains a signed child when attempted-state persistence fails before transport', async () => { + const env = setup(); + env.storage.setItem.mockImplementation(async (key, value) => { + if (value.includes('attempted')) { + throw new Error('disk attempt failure'); + } + env.disk.set(key, value); + }); + await expect(env.service.start(intent, env.io)).rejects.toThrow( + 'disk attempt failure', + ); + expect([...env.disk.values()].join(' ')).toContain('signed'); + expect(env.observed.size).toBe(0); + const restarted = new LighterChaseService({ storage: env.storage }); + const records = await restarted.list(owner, env.io); + expect(records[0].children[0].placement.phase).toBe('failed'); + expect(records[0].status).toBe('failed'); + }); + it.each([ + { + field: 'duration', + next: 160000, + quote: '100000', + max: 2, + status: 'duration_reached', + }, + { + field: 'distance', + next: 101000, + quote: '101001', + max: 2, + status: 'max_distance_reached', + }, + { + field: 'count', + next: 101000, + quote: '99999', + max: 0, + status: 'repricing_limit_reached', + }, + ])( + 'stops and confirms exact cleanup at $field', + async ({ next, quote, max, status }) => { + const env = setup(); + await env.service.start({ ...intent, maxRepricings: max }, env.io); + env.setNow(next); + env.setQuote(quote); + const result = await env.service.tick(owner, intent.handle, env.io); + expect(result.status).toBe('canceled'); + expect(result.stopReason).toBe(status); + expect(result.children.at(-1)?.observation?.terminal).toBe(true); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }, + ); + it('ignores early ticks and rejects a replacement exceeding the aggregate quote budget', async () => { + const env = setup(); + await env.service.start(intent, env.io); + env.setQuote('100001'); + await env.service.tick(owner, intent.handle, env.io); + expect(env.io.quote).toHaveBeenCalledTimes(1); + env.setNow(101000); + const result = await env.service.tick(owner, intent.handle, env.io); + expect(result.status).not.toBe('active'); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('suspends on a failed book and retains unknown cleanup visibly', async () => { + const env = setup(); + await env.service.start(intent, env.io); + env.setNow(101000); + jest.mocked(env.io.quote).mockRejectedValue(new Error('stale book')); + jest + .mocked(env.io.cancel) + .mockRejectedValue(new Error('cancel unavailable')); + expect((await env.service.tick(owner, intent.handle, env.io)).status).toBe( + 'termination_pending', + ); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('interrupts an in-flight placement before its dispatch hook and never resumes', async () => { + const env = setup(); + const pending = createDeferred(); + const signed = createDeferred(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 8, txHash: 'abcdabcd', expiresAt: 110000 }); + signed.resolve(); + await pending.promise; + await hooks.beforeDispatch(); + }); + const starting = env.service.start(intent, env.io); + await signed.promise; + env.service.interrupt(); + pending.resolve(); + expect((await starting).status).toBe('failed'); + expect((await env.service.list(owner, env.io))[0].status).toBe('failed'); + }); + it('does not report an active session when interrupted after an accepted child', async () => { + const env = setup(); + const original = jest.mocked(env.io.observe).getMockImplementation(); + if (!original) { + throw new Error('Missing observation implementation'); + } + jest + .mocked(env.io.observe) + .mockImplementation(async (child, recordEvidence) => { + const result = await original(child, recordEvidence); + env.service.interrupt(); + return result; + }); + expect((await env.service.start(intent, env.io)).status).toBe( + 'termination_pending', + ); + }); + it('refuses a financial continuation whose quote aged during signing', async () => { + const env = setup(); + jest.mocked(env.io.place).mockImplementation(async (_child, hooks) => { + await hooks.signed({ nonce: 8, txHash: 'abcddcba', expiresAt: 120000 }); + env.setNow(106000); + await hooks.beforeDispatch(); + }); + const result = await env.service.start(intent, env.io); + expect(result.status).toBe('failed'); + expect(result.children[0].placement.phase).toBe('failed'); + expect(result.error).toContain('stale'); + }); + it('refuses stale account/network authority before signing and during reads', async () => { + const env = setup(); + jest.mocked(env.io.assertCurrent).mockImplementation(() => { + throw new Error('owner changed'); + }); + await expect(env.service.start(intent, env.io)).rejects.toThrow( + 'owner changed', + ); + expect(env.io.place).not.toHaveBeenCalled(); + }); + it('does not replace after another provider explicitly terminated the shared durable handle', async () => { + const env = setup(); + await env.service.start(intent, env.io); + const other = new LighterChaseService({ storage: env.storage }); + expect( + (await other.stop(owner, intent.handle, env.io, 'canceled')).status, + ).toBe('canceled'); + env.setNow(101000); + env.setQuote('99999'); + expect((await env.service.tick(owner, intent.handle, env.io)).status).toBe( + 'canceled', + ); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('explicitly terminates a restarted owned session without reconnect replay', async () => { + const env = setup(); + await env.service.start(intent, env.io); + const restarted = new LighterChaseService({ storage: env.storage }); + expect((await restarted.list(owner, env.io))[0].status).toBe( + 'termination_pending', + ); + expect( + (await restarted.stop(owner, intent.handle, env.io, 'canceled')).status, + ).toBe('canceled'); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('keeps local stop of a settled handle from interrupting newer or unrelated sessions', async () => { + const env = setup(); + await env.service.start(intent, env.io); + const settled = await env.service.stop( + owner, + intent.handle, + env.io, + 'canceled', + ); + const newer = { ...intent, handle: 'lighter-chase:200' }; + const unrelated = { + ...intent, + handle: 'lighter-chase:300', + owner: { ...owner, accountIndex: 29 }, + }; + const current = await env.service.start(newer, env.io); + const other = await env.service.start(unrelated, env.io); + + expect( + await env.service.stopLocally( + owner, + intent.handle, + { assertCurrent: env.io.assertCurrent }, + 'backgrounded', + 'authority unavailable', + ), + ).toStrictEqual(settled); + + expect((await env.service.list(owner, env.io))[1]).toStrictEqual(current); + expect((await env.service.list(unrelated.owner, env.io))[0]).toStrictEqual( + other, + ); + env.setNow(101000); + env.setQuote('99999'); + expect( + (await env.service.tick(owner, newer.handle, env.io)).repricings, + ).toBe(1); + expect( + (await env.service.tick(unrelated.owner, unrelated.handle, env.io)) + .repricings, + ).toBe(1); + }); + it('rejects a local stop when its session changes during the journal read', async () => { + const env = setup(); + const retained = await env.service.start(intent, env.io); + const entered = createDeferred(); + const pending = createDeferred(); + let current = true; + const assertCurrent = (): void => { + if (!current) { + throw new Error('session changed'); + } + }; + env.storage.getItem.mockImplementationOnce(async (key) => { + entered.resolve(); + await pending.promise; + return env.disk.get(key) ?? null; + }); + const writes = env.storage.setItem.mock.calls.length; + const reads = jest.mocked(env.io.observe).mock.calls.length; + const stopping = env.service.stopLocally( + owner, + intent.handle, + { assertCurrent }, + 'backgrounded', + 'authority unavailable', + ); + await entered.promise; + current = false; + pending.resolve(); + + await expect(stopping).rejects.toThrow('session changed'); + + expect(env.storage.setItem).toHaveBeenCalledTimes(writes); + expect(jest.mocked(env.io.observe)).toHaveBeenCalledTimes(reads); + expect(env.io.cancel).not.toHaveBeenCalled(); + expect((await env.service.list(owner, env.io))[0].children).toStrictEqual( + retained.children, + ); + }); + it('rejects failed local stop persistence without consuming cancellation ownership', async () => { + const env = setup(); + const retained = await env.service.start(intent, env.io); + env.storage.setItem.mockRejectedValueOnce( + new Error('local stop storage unavailable'), + ); + + await expect( + env.service.stopLocally( + owner, + intent.handle, + { assertCurrent: env.io.assertCurrent }, + 'backgrounded', + 'authority unavailable', + ), + ).rejects.toThrow('local stop storage unavailable'); + + expect(env.io.cancel).not.toHaveBeenCalled(); + expect((await env.service.list(owner, env.io))[0].children).toStrictEqual( + retained.children, + ); + }); + it('checks the absolute duration before the interval throttle', async () => { + const env = setup(); + await env.service.start( + { ...intent, intervalMs: 120000, maxDurationMs: 180000 }, + env.io, + ); + env.setNow(220000); + await env.service.tick(owner, intent.handle, env.io); + env.setNow(280000); + const result = await env.service.tick(owner, intent.handle, env.io); + expect(result).toMatchObject({ + status: 'canceled', + stopReason: 'duration_reached', + }); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + expect(env.io.place).toHaveBeenCalledTimes(1); + }); + it('requests duration cleanup after a slow quote without replacing', async () => { + const env = setup(); + await env.service.start(intent, env.io); + env.setNow(159000); + jest.mocked(env.io.quote).mockImplementation(async () => { + env.setNow(160000); + return '99999'; + }); + const result = await env.service.tick(owner, intent.handle, env.io); + expect(result).toMatchObject({ + status: 'canceled', + stopReason: 'duration_reached', + }); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }); + it('keeps a settled stop reason and status unchanged on later cancellation', async () => { + const env = setup(); + await env.service.start(intent, env.io); + env.setNow(160000); + const stopped = await env.service.tick(owner, intent.handle, env.io); + const repeated = await env.service.stop( + owner, + intent.handle, + env.io, + 'backgrounded', + ); + expect(repeated).toStrictEqual(stopped); + expect(repeated).toMatchObject({ + status: 'canceled', + stopReason: 'duration_reached', + }); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }); + it('preserves the first stop reason while cleanup remains uncertain', async () => { + const env = setup(); + await env.service.start(intent, env.io); + jest.mocked(env.io.cancel).mockRejectedValue(new Error('unavailable')); + await env.service.stop(owner, intent.handle, env.io, 'canceled'); + const result = await env.service.stop( + owner, + intent.handle, + env.io, + 'backgrounded', + ); + expect(result.stopReason).toBe('canceled'); + expect(result.status).toBe('termination_pending'); + }); + it('retains the explicit stop reason requested during an in-flight quote', async () => { + const env = setup(); + await env.service.start(intent, env.io); + env.setNow(101000); + const quoted = createDeferred(); + const pending = createDeferred(); + jest.mocked(env.io.quote).mockImplementation(async () => { + quoted.resolve(); + return await pending.promise; + }); + const ticking = env.service.tick(owner, intent.handle, env.io); + await quoted.promise; + const stopping = env.service.stop(owner, intent.handle, env.io, 'canceled'); + pending.resolve('99999'); + await ticking; + expect(await stopping).toMatchObject({ + status: 'canceled', + stopReason: 'canceled', + }); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterChaseService.units.test.ts b/packages/perps-controller/tests/src/services/LighterChaseService.units.test.ts new file mode 100644 index 00000000000..83c2131daec --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterChaseService.units.test.ts @@ -0,0 +1,188 @@ +import { + LighterChaseService, + toLighterChaseOrder, +} from '../../../src/services/LighterChaseService.js'; +import type { + LighterChaseIntent, + LighterChaseIo, + LighterChaseRecord, +} from '../../../src/services/LighterChaseService.js'; +import type { LighterChaseChildObservation } from '../../../src/utils/lighterChase.js'; + +// Vary policy in this isolated module to prove conversion units are independent. +jest.mock('../../../src/constants/lighterConfig', () => ({ + ...jest.requireActual< + typeof import('../../../src/constants/lighterConfig.js') + >('../../../src/constants/lighterConfig'), + LIGHTER_CHASE_MAX_DISTANCE_BPS: 5000, +})); + +type UnitFixture = { + intent: LighterChaseIntent; + io: LighterChaseIo; + service: LighterChaseService; + setQuote: (value: string) => void; + advance: () => void; +}; +function fixture(isBuy: boolean): UnitFixture { + const intent: LighterChaseIntent = { + owner: { + wallet: '0xabc', + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 7, + }, + handle: 'lighter-chase:123', + symbol: 'BTC', + marketId: 1, + isBuy, + reduceOnly: false, + originalSize: '0.019', + arrivalPrice: '1000', + sizeDecimals: 3, + priceDecimals: 1, + startedAt: 100000, + intervalMs: 1000, + maxDurationMs: 10000, + maxRepricings: 3, + maxDistanceBps: 10, + maxNotional: '20', + minBaseAmount: '0.001', + minQuoteAmount: '1', + }; + const disk = new Map(); + const observed = new Map(); + let now = intent.startedAt; + let quote = intent.arrivalPrice; + let id = 123; + const readObservation = (clientId: string): LighterChaseChildObservation => { + const row = observed.get(clientId); + if (!row) { + throw new Error('Missing owned child observation'); + } + return { ...row }; + }; + const io: LighterChaseIo = { + assertCurrent: () => undefined, + now: () => now, + allocateClientId: () => { + id += 1; + return String(id); + }, + quote: async () => quote, + place: jest.fn(async (child, hooks) => { + await hooks.signed({ + nonce: id, + txHash: 'aabbccdd', + expiresAt: now + 10000, + }); + await hooks.beforeDispatch(); + observed.set(child.clientOrderId, { + orderId: `9${child.clientOrderId}`, + terminal: false, + filledSize: '0', + filledNotional: '0', + remainingSize: child.size, + }); + }), + cancel: jest.fn(async (child, hooks) => { + id += 1; + await hooks.signed({ + nonce: id, + txHash: 'eeffccdd', + expiresAt: now + 10000, + }); + await hooks.beforeDispatch(); + observed.set(child.clientOrderId, { + ...readObservation(child.clientOrderId), + terminal: true, + }); + }), + observe: async (child) => readObservation(child.clientOrderId), + }; + const service = new LighterChaseService({ + storage: { + getItem: async (key): Promise => disk.get(key) ?? null, + setItem: async (key, value): Promise => { + disk.set(key, value); + }, + }, + }); + return { + intent, + io, + service, + setQuote: (value: string): void => { + quote = value; + }, + advance: (): void => { + now += 1000; + }, + }; +} + +describe('Lighter Chase basis-point units with a different policy ceiling', () => { + it.each([true, false])( + 'projects an adverse 0.2 percent move as 20 bps for buy=%s', + (isBuy) => { + const env = fixture(isBuy); + const record: LighterChaseRecord = { + intent: env.intent, + status: 'active', + repricings: 0, + lastTickAt: env.intent.startedAt, + executedSize: '0', + executedNotional: '0', + children: [ + { + clientOrderId: '123', + size: env.intent.originalSize, + price: isBuy ? '1002' : '998', + quotedAt: env.intent.startedAt, + placement: { phase: 'prepared' }, + cancellations: [], + }, + ], + }; + + const result = toLighterChaseOrder(record); + + expect(result.distanceChasedBps).toBe(20); + }, + ); + it.each([true, false])( + 'rejects a 20 bps initial quote beyond a 10 bps distance for buy=%s', + async (isBuy) => { + const env = fixture(isBuy); + env.setQuote(isBuy ? '1002' : '998'); + + const record = await env.service.start(env.intent, env.io); + + expect(record.status).toBe('failed'); + expect(record.error).toContain('maximum distance'); + expect(env.io.place).not.toHaveBeenCalled(); + }, + ); + it.each([true, false])( + 'terminates before replacement at 20 bps with a 10 bps distance for buy=%s', + async (isBuy) => { + const env = fixture(isBuy); + await env.service.start(env.intent, env.io); + env.setQuote(isBuy ? '1002' : '998'); + env.advance(); + + const record = await env.service.tick( + env.intent.owner, + env.intent.handle, + env.io, + ); + + expect(record).toMatchObject({ + status: 'canceled', + stopReason: 'max_distance_reached', + }); + expect(env.io.place).toHaveBeenCalledTimes(1); + expect(env.io.cancel).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/services/LighterClientService.test.ts b/packages/perps-controller/tests/src/services/LighterClientService.test.ts index 9a10d620dc1..a66f2718bb9 100644 --- a/packages/perps-controller/tests/src/services/LighterClientService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterClientService.test.ts @@ -77,6 +77,131 @@ describe('LighterClientService', () => { global.fetch = fetchMock; }); + describe('lossless native edit reads', () => { + const raw = + '{"code":200,"orders":[{"order_index":288230376151711745,"client_order_index":1,"market_index":1,"owner_account_index":28,"initial_base_amount":"0.001","remaining_base_amount":"0.001","filled_base_amount":"0","price":"90000","is_ask":false,"type":"limit","time_in_force":"good-till-time","reduce_only":0,"status":"open","order_expiry":0,"timestamp":1700000000}]}'; + it('preserves a raw unquoted int64 venue identity in active reads', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(raw), + }); + const result = await buildService().getEditableOrders(28, 'auth'); + expect(result.orders[0].orderIndex).toBe('288230376151711745'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/accountActiveOrders?account_index=28'), + expect.any(Object), + ); + }); + it('reconciles with the documented client-order lookup rather than venue IDs', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(raw), + }); + await buildService().getEditableOrders(28, 'auth', ['1']); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + '/api/v1/accountOrders?account_index=28&client_order_indexes=1', + ), + expect.any(Object), + ); + await expect( + buildService().getEditableOrders(28, 'auth', ['288230376151711745']), + ).rejects.toThrow(Error); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('refuses distinct wire keys that collapse to the same financial identity', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: jest + .fn() + .mockResolvedValue( + raw.replace( + '"client_order_index":1', + '"client_order_index":1,"clientOrderIndex":2', + ), + ), + }); + await expect( + buildService().getEditableOrders(28, 'auth'), + ).rejects.toThrow('Ambiguous Lighter response keys'); + }); + it.each(['1.00000000000000001', '1152921504606846976'])( + 'rejects lossy or out-of-range order identity %s', + async (id) => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: jest + .fn() + .mockResolvedValue(raw.replace('288230376151711745', id)), + }); + await expect( + buildService().getEditableOrders(28, 'auth'), + ).rejects.toThrow(Error); + }, + ); + }); + + describe('post-only book reads', () => { + it('requests a bounded fresh public book and validates its full order identities', async () => { + const row = { + order_index: 5, + order_id: '5', + owner_account_index: 28, + initial_base_amount: '1', + remaining_base_amount: '1', + price: '100', + order_expiry: 0, + transaction_time: 1, + }; + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + total_bids: 1, + total_asks: 0, + bids: [row], + asks: [], + }), + ); + expect(await buildService().getOrderBookOrders(1)).toMatchObject({ + totalBids: 1, + bids: [{ orderId: '5', ownerAccountIndex: 28 }], + }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + '/api/v1/orderBookOrders?market_id=1&limit=250', + ), + expect.anything(), + ); + }); + it.each([-1, 32768, 1.5])( + 'refuses invalid market %s without transport', + async (id) => { + await expect(buildService().getOrderBookOrders(id)).rejects.toThrow( + 'market', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + it('rejects malformed public book levels', async () => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + total_bids: 1, + total_asks: 0, + bids: [{ price: 'garbage' }], + asks: [], + }), + ); + await expect(buildService().getOrderBookOrders(1)).rejects.toThrow( + 'Lighter', + ); + }); + }); + describe('network resolution', () => { it('uses the testnet base URL in testnet mode', () => { expect(buildService(true).baseUrl).toBe( @@ -308,6 +433,65 @@ describe('LighterClientService', () => { timestamp: 1700000000, }; + it.each(['getActiveOrders', 'getInactiveOrders'] as const)( + 'accepts zero native attached children with explicit parent linkage through %s', + async (method) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orders: [ + { + ...order, + initial_base_amount: '0', + remaining_base_amount: '0', + type: 'stop-loss', + reduce_only: 1, + parent_order_index: 100, + trigger_price: '80000', + status: 'pending', + }, + ], + }), + ); + const result = await buildService()[method](28, 'auth-token'); + expect(result.orders[0]).toMatchObject({ + initialBaseAmount: '0', + parentOrderIndex: 100, + status: 'pending', + }); + }, + ); + + it.each([ + { type: 'limit' }, + { reduce_only: 0 }, + { parent_order_index: undefined }, + { parent_order_index: 0 }, + { initial_base_amount: '-1' }, + ])('refuses an unproven zero-size child: %j', async (invalid) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + orders: [ + { + ...order, + initial_base_amount: '0', + remaining_base_amount: '0', + type: 'stop-loss', + reduce_only: 1, + parent_order_index: 100, + trigger_price: '80000', + status: 'pending', + ...invalid, + }, + ], + }), + ); + await expect( + buildService().getActiveOrders(28, 'auth-token'), + ).rejects.toThrow('Invalid Lighter venue data'); + }); + it.each(['0', '0.2', undefined])( 'accepts reported filled amount %s', async (filledBaseAmount) => { @@ -345,6 +529,52 @@ describe('LighterClientService', () => { ); }); + describe('native TWAP exact reads', () => { + it('queries exact client IDs with explicit account and authorization', async () => { + fetchMock.mockResolvedValue(mockJsonResponse({ code: 200, orders: [] })); + await buildService().getOrdersByClientIds(28, 'auth-token', ['12', '34']); + expect(fetchMock).toHaveBeenCalledWith( + 'https://testnet.zklighter.elliot.ai/api/v1/accountOrders?account_index=28&client_order_indexes=12%2C34', + expect.objectContaining({ headers: { authorization: 'auth-token' } }), + ); + }); + + it.each( + [ + [], + ['1tail'], + ['281474976710656'], + ['01'], + ['1', '1'], + Array.from({ length: 21 }, (_, index) => String(index + 1)), + ].map((ids) => [ids]), + )( + 'rejects invalid exact client ID lists %j before transport', + async (ids) => { + await expect( + buildService().getOrdersByClientIds(28, 'auth-token', ids), + ).rejects.toThrow('client order'); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it('queries unaggregated trades by exact order ID', async () => { + fetchMock.mockResolvedValue(mockJsonResponse({ code: 200, trades: [] })); + await buildService().getTrades(28, 'auth-token', { + limit: 100, + marketId: 4097, + orderIndex: '1152921504606846975', + aggregate: false, + }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + '&order_index=1152921504606846975&aggregate=false', + ), + expect.anything(), + ); + }); + }); + describe('getTrades', () => { it.each([ { taker_position_sign_changed: undefined }, @@ -738,6 +968,56 @@ describe('LighterClientService', () => { ); }); + it.each([ + { pending_order_count: -1 }, + { position_tied_order_count: 0.5 }, + { allocated_margin: '-1' }, + { allocated_margin: '5USDC' }, + ])( + 'rejects malformed collateral and pending inventory fields %j', + async (change) => { + fetchMock.mockResolvedValue( + mockJsonResponse({ + code: 200, + accounts: [ + { + code: 200, + account_type: 0, + index: 28, + l1_address: '0xabc', + cancel_all_time: 0, + total_order_count: 0, + pending_order_count: 0, + status: 1, + collateral: '10000', + available_balance: '9000', + positions: [ + { + market_id: 1, + symbol: 'BTC', + initial_margin_fraction: '20', + open_order_count: 0, + sign: 1, + position: '0.1', + avg_entry_price: '100000', + position_value: '10000', + unrealized_pnl: '0', + realized_pnl: '0', + liquidation_price: '80000', + margin_mode: 1, + ...change, + }, + ], + }, + ], + }), + ); + await expect(buildService().getAccountByIndex(28)).rejects.toThrow( + 'Invalid Lighter venue data', + ); + }, + ); + it('queries the account by index', async () => { fetchMock.mockResolvedValue( mockJsonResponse({ code: 200, accounts: [] }), diff --git a/packages/perps-controller/tests/src/services/LighterTwapService.test.ts b/packages/perps-controller/tests/src/services/LighterTwapService.test.ts new file mode 100644 index 00000000000..b0549981ee8 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterTwapService.test.ts @@ -0,0 +1,773 @@ +import type { LighterClientService } from '../../../src/services/LighterClientService.js'; +import { LighterTwapService } from '../../../src/services/LighterTwapService.js'; +import type { + LighterTwapIntent, + LighterTwapRecord, +} from '../../../src/services/LighterTwapService.js'; + +const intent: LighterTwapIntent = { + owner: { + wallet: '0xabc', + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 3, + }, + symbol: 'SOL', + marketId: 4097, + clientOrderId: '123', + size: '0.15', + price: '121', + isBuy: true, + reduceOnly: false, + sizeDecimals: 3, + priceDecimals: 3, + durationMinutes: 1, + startedAt: 1_800_000_000_000, + orderExpiry: 1_800_000_060_000, +}; +const identity = { nonce: 4, txHash: 'abcdabcd', expiresAt: 1_800_000_100_000 }; + +const readDisk = (raw: string): { records: LighterTwapRecord[] } => + JSON.parse(raw) as { records: LighterTwapRecord[] }; + +const build = ( + disk = new Map(), + terminalMappingVerified = false, +): { + service: LighterTwapService; + storage: { + getItem: jest.Mock, [string]>; + setItem: jest.Mock, [string, string]>; + }; + disk: Map; + assertCurrent: jest.Mock; +} => { + const storage = { + getItem: jest.fn(async (key: string) => disk.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + disk.set(key, value); + }), + }; + const assertCurrent = jest.fn(); + const service = new LighterTwapService({ + storage, + assertCurrent, + terminalMappingVerified, + }); + return { service, storage, disk, assertCurrent }; +}; + +describe('LighterTwapService durable ownership', () => { + it.each(['abcd', 'g'.repeat(8), 'a'.repeat(129)])( + 'rejects an unusable signed transaction hash %s before transport', + async (txHash) => { + const { service } = build(); + await expect( + service.place(intent, async (hooks) => { + await hooks.signed({ ...identity, txHash }); + }), + ).rejects.toThrow('identity'); + }, + ); + it('persists immutable intent and exact dispatch identity before transport', async () => { + const { service, disk } = build(); + await service.place(intent, async (hooks) => { + expect(readDisk([...disk.values()][0]).records[0].intent).toStrictEqual( + intent, + ); + await hooks.signed(identity); + await hooks.beforeDispatch(); + expect( + readDisk([...disk.values()][0]).records[0].placement, + ).toMatchObject({ ...identity, phase: 'attempted' }); + }); + expect((await service.list(intent.owner))[0].placement.phase).toBe( + 'acknowledged', + ); + }); + + it('retains uncertain dispatch after a lost response and refuses replay after restart', async () => { + const { service, disk } = build(); + await expect( + service.place(intent, async (hooks) => { + await hooks.signed(identity); + await hooks.beforeDispatch(); + throw new Error('response lost'); + }), + ).rejects.toThrow('response lost'); + const restarted = build(disk).service; + expect((await restarted.list(intent.owner))[0].placement.phase).toBe( + 'attempted', + ); + const send = jest.fn(); + await expect(restarted.place(intent, send)).rejects.toThrow( + 'already tracked', + ); + expect(send).not.toHaveBeenCalled(); + }); + + it('refuses dispatch when persistence fails', async () => { + const { service, storage } = build(); + storage.setItem.mockRejectedValueOnce(new Error('disk full')); + const send = jest.fn(); + await expect(service.place(intent, send)).rejects.toThrow('disk full'); + expect(send).not.toHaveBeenCalled(); + }); + + it('preserves the prepared owner even when the current account changes', async () => { + const { service, assertCurrent, disk } = build(); + await expect( + service.place(intent, async (hooks) => { + await hooks.signed(identity); + assertCurrent.mockImplementation(() => { + throw new Error('stale owner'); + }); + await hooks.beforeDispatch(); + }), + ).rejects.toThrow('stale owner'); + expect(readDisk([...disk.values()][0]).records[0].placement.phase).toBe( + 'signed', + ); + }); + + it('does not report cancel acknowledgment as authoritative termination', async () => { + const { service } = build(); + await service.place(intent, async (hooks) => { + await hooks.signed(identity); + await hooks.beforeDispatch(); + }); + const record = await service.cancel( + intent.owner, + intent.clientOrderId, + async (hooks) => { + await hooks.signed({ ...identity, nonce: 5, txHash: 'cdefcdef' }); + await hooks.beforeDispatch(); + }, + ); + expect(record.cancellations[0].phase).toBe('acknowledged'); + expect(record.terminalConfirmed).toBe(false); + await expect( + service.cancel(intent.owner, intent.clientOrderId, jest.fn()), + ).rejects.toThrow('cancellation remains unresolved'); + }); + + it('rejects corrupted persisted ownership instead of reporting empty inventory', async () => { + const { service, disk } = build(); + await service + .place(intent, async () => { + throw new Error('unsent'); + }) + .catch(() => undefined); + const key = [...disk.keys()][0]; + const raw = readDisk(disk.get(key) ?? ''); + raw.records[0].intent.owner.accountIndex = 29; + disk.set(key, JSON.stringify(raw)); + await expect(service.list(intent.owner)).rejects.toThrow('ownership'); + }); +}); + +describe('LighterTwapService read-only lifecycle reconciliation', () => { + const parent = { + orderIndex: 1000, + orderId: '1000', + clientOrderIndex: 123, + clientOrderId: '123', + marketIndex: 4097, + ownerAccountIndex: 28, + initialBaseAmount: '0.15', + remainingBaseAmount: '0.15', + filledBaseAmount: '0', + filledQuoteAmount: '0', + price: '121', + isAsk: false, + type: 'twap', + timeInForce: 'good-till-time', + reduceOnly: false, + status: 'in-progress', + orderExpiry: intent.orderExpiry, + timestamp: 1_800_000_000, + nonce: 4, + }; + const readClient = ( + status = 'in-progress', + ): jest.Mocked< + Pick< + LighterClientService, + | 'getOrdersByClientIds' + | 'getActiveOrders' + | 'getInactiveOrders' + | 'getTrades' + | 'getTx' + > + > => { + const row = { ...parent, status }; + return { + getOrdersByClientIds: jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({ code: 200, orders: [row] }), + getActiveOrders: jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({ + code: 200, + orders: status === 'in-progress' ? [row] : [], + }), + getInactiveOrders: jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({ + code: 200, + orders: status === 'in-progress' ? [] : [row], + }), + getTrades: jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({ code: 200, trades: [] }), + getTx: jest.fn(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 3, + nonce: hash === 'abcdabcd' ? 4 : 5, + status: 2, + })), + }; + }; + const sendPlacement = async ( + service: LighterTwapService, + ): Promise => + await service.place(intent, async (hooks) => { + await hooks.signed(identity); + await hooks.beforeDispatch(); + }); + + it('recovers exact parent and observed fills after restart without signing or replay', async () => { + const { service, disk } = build(); + await sendPlacement(service); + const restarted = build(disk).service; + const client = readClient(); + const observations = await restarted.observe( + intent.owner, + client, + 'read-token', + ); + expect(observations[0].observation?.order).toMatchObject({ + orderId: '1000', + executedSize: '0', + status: 'active', + }); + expect(observations[0].record.placement.phase).toBe('succeeded'); + expect(client.getOrdersByClientIds).toHaveBeenCalledWith(28, 'read-token', [ + '123', + ]); + expect((await restarted.list(intent.owner))[0].parentOrderId).toBe('1000'); + }); + + it('keeps terminal observation gated even after matching executed cancel', async () => { + const { service } = build(); + await sendPlacement(service); + await service.cancel(intent.owner, '123', async (hooks) => { + await hooks.signed({ ...identity, nonce: 5, txHash: 'cdefcdef' }); + await hooks.beforeDispatch(); + }); + const observed = await service.observe( + intent.owner, + readClient('canceled'), + 'read-token', + ); + expect(observed[0].observation?.terminalObserved).toBe(true); + expect(observed[0].record.terminalConfirmed).toBe(false); + }); + + it('requires exact executed cancellation before verified terminal settlement', async () => { + const { service } = build(new Map(), true); + await sendPlacement(service); + await service.cancel(intent.owner, '123', async (hooks) => { + await hooks.signed({ ...identity, nonce: 5, txHash: 'cdefcdef' }); + await hooks.beforeDispatch(); + }); + const client = readClient('canceled'); + client.getTx.mockImplementation(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 3, + nonce: hash === 'abcdabcd' ? 4 : 5, + status: hash === 'abcdabcd' ? 2 : 1, + })); + expect( + (await service.observe(intent.owner, client, 'read-token'))[0].record + .terminalConfirmed, + ).toBe(false); + client.getTx.mockImplementation(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 3, + nonce: hash === 'abcdabcd' ? 4 : 5, + status: 2, + })); + expect( + (await service.observe(intent.owner, client, 'read-token'))[0].record + .terminalConfirmed, + ).toBe(true); + const send = jest.fn(); + await service.cancel(intent.owner, '123', send); + expect(send).not.toHaveBeenCalled(); + }); + + it('retains an explicit unknown when history pagination is incomplete', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + client.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + nextCursor: 'still-more', + }); + const observed = await service.observe(intent.owner, client, 'read-token'); + expect(observed[0].issue).toContain('pagination is incomplete'); + expect(observed[0].observation).toBeUndefined(); + expect(observed[0].record.terminalConfirmed).toBe(false); + }); + + it('refuses mismatched transaction identity and preserves the original pending record', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + client.getTx.mockResolvedValue({ + code: 200, + hash: 'different', + accountIndex: 28, + apiKeyIndex: 3, + nonce: 4, + status: 2, + }); + const observed = await service.observe(intent.owner, client, 'read-token'); + expect(observed[0].issue).toContain('identity mismatch'); + expect(observed[0].record.placement.phase).toBe('acknowledged'); + }); + + it('rejects a changing account during history reads without writing another owner', async () => { + const { service, assertCurrent, storage } = build(); + await sendPlacement(service); + const before = storage.setItem.mock.calls.length; + const client = readClient(); + client.getActiveOrders.mockImplementation(async () => { + assertCurrent.mockImplementation(() => { + throw new Error('account changed'); + }); + return { code: 200, orders: [parent] }; + }); + await expect( + service.observe(intent.owner, client, 'read-token'), + ).rejects.toThrow('account changed'); + expect(storage.setItem).toHaveBeenCalledTimes(before); + }); + + describe('TWAP review corrections', () => { + it('accepts the provider-supported trading slot 2', async () => { + const { service } = build(); + const slot2 = { ...intent, owner: { ...intent.owner, apiKeyIndex: 2 } }; + await service.place(slot2, async (hooks) => { + await hooks.signed(identity); + await hooks.beforeDispatch(); + }); + expect((await service.list(slot2.owner))[0].placement.phase).toBe( + 'acknowledged', + ); + }); + it('releases a signed placement refused by the nonce ledger before transport', async () => { + const { service } = build(); + await expect( + service.place(intent, async (hooks) => { + await hooks.signed(identity); + throw new Error('nonce ledger full'); + }), + ).rejects.toThrow('nonce ledger full'); + expect((await service.list(intent.owner))[0].placement.phase).toBe( + 'failed', + ); + await service.place( + { ...intent, clientOrderId: '124' }, + async (hooks) => { + await hooks.signed({ ...identity, nonce: 5 }); + await hooks.beforeDispatch(); + }, + ); + }); + it.each(['prepared', 'signed'] as const)( + 'releases persisted %s ownership after a crash or stale session', + async (phase) => { + const { service, disk } = build(); + await sendPlacement(service); + const key = [...disk.keys()][0]; + const doc = readDisk(disk.get(key) ?? ''); + doc.records[0].placement = + phase === 'prepared' ? { phase } : { phase, ...identity }; + disk.set(key, JSON.stringify({ version: 1, records: doc.records })); + const client = readClient(); + client.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [], + }); + client.getActiveOrders.mockResolvedValue({ code: 200, orders: [] }); + const restarted = build(disk).service; + await restarted.observe(intent.owner, client, 'read-token'); + expect((await restarted.list(intent.owner))[0].placement.phase).toBe( + 'failed', + ); + }, + ); + + it.each(['active', 'history'] as const)( + 'retains expired placement uncertainty after incomplete %s reads across restart', + async (failure) => { + const { service, disk } = build(); + await sendPlacement(service); + const client = readClient(); + client.getTx.mockResolvedValue(null); + if (failure === 'active') { + client.getActiveOrders.mockRejectedValue( + new Error('active read unavailable'), + ); + } else { + client.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [], + nextCursor: 'incomplete', + }); + } + const now = jest + .spyOn(Date, 'now') + .mockReturnValue(identity.expiresAt + 30001); + try { + const observed = await service.observe( + intent.owner, + client, + 'read-token', + ); + expect(observed[0].issue).toBeDefined(); + expect(observed[0].record.placement.phase).toBe('acknowledged'); + const restarted = build(disk).service; + expect((await restarted.list(intent.owner))[0].placement.phase).toBe( + 'acknowledged', + ); + const send = jest.fn(); + await expect( + restarted.place({ ...intent, clientOrderId: '124' }, send), + ).rejects.toThrow('unresolved'); + expect(send).not.toHaveBeenCalled(); + } finally { + now.mockRestore(); + } + }, + ); + it('releases never-landed response loss after signed expiry plus clock slack', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + client.getTx.mockResolvedValue(null); + client.getOrdersByClientIds.mockResolvedValue({ code: 200, orders: [] }); + client.getActiveOrders.mockResolvedValue({ code: 200, orders: [] }); + const now = jest + .spyOn(Date, 'now') + .mockReturnValue(identity.expiresAt + 30001); + try { + await service.observe(intent.owner, client, 'read-token'); + expect((await service.list(intent.owner))[0].placement.phase).toBe( + 'failed', + ); + } finally { + now.mockRestore(); + } + }); + it('retains definitive failed cancel outcomes when later parent reads fail', async () => { + const { service } = build(); + await sendPlacement(service); + await service.cancel(intent.owner, '123', async (hooks) => { + await hooks.signed({ ...identity, nonce: 5, txHash: 'cdefcdef' }); + await hooks.beforeDispatch(); + }); + const client = readClient(); + client.getTx.mockImplementation(async (hash: string) => ({ + code: 200, + hash, + accountIndex: 28, + apiKeyIndex: 3, + nonce: hash === 'abcdabcd' ? 4 : 5, + status: hash === 'abcdabcd' ? 2 : 0, + })); + client.getOrdersByClientIds.mockRejectedValue( + new Error('parent read unavailable'), + ); + await service.observe(intent.owner, client, 'read-token'); + expect((await service.list(intent.owner))[0].cancellations[0].phase).toBe( + 'failed', + ); + await service.cancel(intent.owner, '123', async (hooks) => { + await hooks.signed({ ...identity, nonce: 6, txHash: 'dddddddd' }); + await hooks.beforeDispatch(); + }); + expect((await service.list(intent.owner))[0].cancellations).toHaveLength( + 2, + ); + }); + it('blocks a second market after restart while the first native probe is unresolved', async () => { + const { service, disk } = build(); + await sendPlacement(service); + const restarted = build(disk).service; + await expect( + restarted.place( + { ...intent, marketId: 4096, symbol: 'BTC', clientOrderId: '124' }, + jest.fn(), + ), + ).rejects.toThrow('unresolved'); + }); + it('retains bounded native history and cancel attempt capacity', async () => { + const { service, disk } = build(); + await sendPlacement(service); + const key = [...disk.keys()][0]; + const doc = readDisk(disk.get(key) ?? ''); + doc.records[0].cancellations = Array.from({ length: 16 }, () => ({ + phase: 'failed' as const, + })); + disk.set(key, JSON.stringify({ version: 1, records: doc.records })); + await expect( + service.cancel(intent.owner, '123', jest.fn()), + ).rejects.toThrow('attempt limit'); + doc.records = Array.from({ length: 64 }, (_, index) => ({ + ...doc.records[0], + intent: { ...intent, clientOrderId: String(index + 1000) }, + placement: { phase: 'failed' as const }, + })); + disk.set(key, JSON.stringify({ version: 1, records: doc.records })); + await expect( + service.place({ ...intent, clientOrderId: '2000' }, jest.fn()), + ).rejects.toThrow('capacity'); + }); + it('reconciles two history pages and unequal children with three paginated trades', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + const filledParent = { + ...parent, + filledBaseAmount: '0.05', + filledQuoteAmount: '6', + remainingBaseAmount: '0.10', + }; + const children = ['0.02', '0.03'].map((size, index) => ({ + ...parent, + orderIndex: 1001 + index, + orderId: String(1001 + index), + clientOrderIndex: 124 + index, + clientOrderId: String(124 + index), + parentOrderIndex: parent.orderIndex, + parentOrderId: parent.orderId, + type: 'twap-sub', + status: 'filled', + initialBaseAmount: size, + remainingBaseAmount: '0', + filledBaseAmount: size, + filledQuoteAmount: index === 0 ? '2.4' : '3.6', + })); + client.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [filledParent], + }); + client.getActiveOrders.mockResolvedValue({ + code: 200, + orders: [filledParent], + }); + client.getInactiveOrders + .mockResolvedValueOnce({ + code: 200, + orders: [children[0]], + nextCursor: 'child-page-2', + }) + .mockResolvedValueOnce({ code: 200, orders: [children[1]] }); + client.getTrades.mockImplementation(async (_account, _token, options) => { + const secondChild = options?.orderIndex === '1002'; + const secondPage = options?.cursor === 'trade-page-2'; + return { + code: 200, + trades: [ + { + tradeId: secondChild ? 3 : Number(secondPage) + 1, + txHash: 'abc1', + type: 'trade', + marketId: intent.marketId, + size: secondChild ? '0.03' : '0.01', + price: '120', + usdAmount: secondChild ? '3.6' : '1.2', + askId: 999, + bidId: secondChild ? 1002 : 1001, + askAccountId: 99, + bidAccountId: 28, + isMakerAsk: true, + timestamp: intent.startedAt + 1000, + makerPositionSizeBefore: '0', + takerPositionSizeBefore: '0', + }, + ], + ...(!secondChild && !secondPage + ? { nextCursor: 'trade-page-2' } + : {}), + }; + }); + const [result] = await service.observe( + intent.owner, + client, + 'read-token', + ); + expect(result.issue).toBeUndefined(); + expect(result.observation?.order).toMatchObject({ + executedSize: '0.05', + executedNotional: '6', + averagePrice: '120', + }); + expect(result.observation?.order.fills).toHaveLength(3); + expect(client.getInactiveOrders).toHaveBeenCalledTimes(2); + expect(client.getTrades).toHaveBeenCalledTimes(3); + }); + + it.each(['repeat', 'capacity'] as const)( + 'refuses %s trade cursors without accepting a partial lifecycle', + async (mode) => { + const { service, disk } = build(); + await sendPlacement(service); + const client = readClient(); + client.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [ + { + ...parent, + orderIndex: 1001, + orderId: '1001', + clientOrderIndex: 124, + clientOrderId: '124', + parentOrderIndex: 1000, + parentOrderId: '1000', + type: 'twap-sub', + }, + ], + }); + let page = 0; + client.getTrades.mockImplementation(async () => { + page += 1; + return { + code: 200, + trades: [ + { + tradeId: page, + txHash: 'abcdabcd', + marketId: intent.marketId, + size: '0.01', + price: '120', + usdAmount: '1.2', + bidId: 1001, + askId: 999, + bidAccountId: 28, + askAccountId: 99, + isMakerAsk: true, + timestamp: Math.floor(intent.startedAt / 1000) + 1, + type: 'trade', + takerPositionSizeBefore: '0', + makerPositionSizeBefore: '0', + }, + ], + nextCursor: mode === 'repeat' ? 'repeat' : String(page), + }; + }); + const [result] = await service.observe( + intent.owner, + client, + 'read-token', + ); + expect(result.issue).toContain( + mode === 'repeat' + ? 'trade pagination is incomplete' + : 'trade pagination limit reached', + ); + expect(result.observation).toBeUndefined(); + expect(client.getTrades).toHaveBeenCalledTimes( + mode === 'repeat' ? 2 : 100, + ); + expect( + (await build(disk).service.list(intent.owner))[0].placement.phase, + ).toBe('acknowledged'); + }, + ); + it('rejects conflicting exact client ID counterparts before parent collection', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + client.getOrdersByClientIds.mockResolvedValue({ + code: 200, + orders: [{ ...parent, clientOrderId: '124' }], + }); + const [result] = await service.observe( + intent.owner, + client, + 'read-token', + ); + expect(result.issue).toBe( + 'Lighter TWAP order identity is unsafe or inconsistent', + ); + expect(client.getTrades).not.toHaveBeenCalled(); + }); + it('refuses repeated history cursors without accepting a partial snapshot', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + client.getInactiveOrders.mockResolvedValue({ + code: 200, + orders: [parent], + nextCursor: 'repeat', + }); + const [result] = await service.observe( + intent.owner, + client, + 'read-token', + ); + expect(result.issue).toBe( + 'Lighter TWAP order history pagination is incomplete', + ); + expect(client.getInactiveOrders).toHaveBeenCalledTimes(2); + }); + it('refuses a history page cap without discarding pending ownership', async () => { + const { service } = build(); + await sendPlacement(service); + const client = readClient(); + let page = 0; + client.getInactiveOrders.mockImplementation(async () => { + page += 1; + return { + code: 200, + orders: [ + { + ...parent, + orderIndex: 5000 + page, + orderId: String(5000 + page), + clientOrderIndex: 6000 + page, + clientOrderId: String(6000 + page), + }, + ], + nextCursor: String(page), + }; + }); + const result = await service.observe(intent.owner, client, 'read-token'); + expect(result[0].issue).toContain('pagination limit'); + expect(client.getInactiveOrders).toHaveBeenCalledTimes(100); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingService.scale-refusal.test.ts b/packages/perps-controller/tests/src/services/TradingService.scale-refusal.test.ts new file mode 100644 index 00000000000..9d9f7b283d5 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TradingService.scale-refusal.test.ts @@ -0,0 +1,153 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { RewardsIntegrationService } from '../../../src/services/RewardsIntegrationService.js'; +import { TradingService } from '../../../src/services/TradingService.js'; +import type { OrderParams } from '../../../src/types/index.js'; +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; +import { + createMockInfrastructure, + createMockMessenger, + createMockServiceContext, +} from '../../helpers/serviceMocks.js'; + +type ReportOrderToDataLake = Parameters< + TradingService['placeOrder'] +>[0]['reportOrderToDataLake']; + +describe('TradingService malformed Scale expectation refusal', () => { + function setup(): { + service: TradingService; + provider: ReturnType; + placeOrder: jest.SpyInstance>; + resolveFee: jest.SpyInstance< + ReturnType + >; + context: ReturnType; + reportOrderToDataLake: jest.MockedFunction; + } { + const dependencies = createMockInfrastructure(); + const rewards = new RewardsIntegrationService( + dependencies, + createMockMessenger(), + ); + const resolveFee = jest.spyOn(rewards, 'resolveFee').mockResolvedValue({ + feeBips: 10, + discountBips: undefined, + source: 'default', + subscription: { eligible: false, reason: 'no-source' }, + }); + const service = new TradingService(dependencies); + service.setControllerDependencies({ rewardsIntegrationService: rewards }); + const provider = createMockHyperLiquidProvider(); + const placeOrder = jest + .spyOn(provider, 'placeOrder') + .mockResolvedValue({ success: true }); + const context = createMockServiceContext(); + const reportOrderToDataLake = jest + .fn< + ReturnType, + Parameters + >() + .mockResolvedValue({ success: true }); + return { + service, + provider, + placeOrder, + resolveFee, + context, + reportOrderToDataLake, + }; + } + + const validExpectation = { + prices: ['100', '200'], + sizes: ['1', '1'], + totalSize: '2', + totalNotional: '300', + minimumBaseSize: '0.1', + minimumQuoteAmount: '1', + sizeDecimals: 1, + }; + const malformed: { name: string; value: unknown }[] = [ + { name: 'null', value: null }, + { name: 'missing both arrays', value: {} }, + { name: 'missing sizes', value: { prices: ['100'] } }, + { + name: 'unknown expectation field', + value: { ...validExpectation, unknownField: true }, + }, + ]; + + it.each(malformed)( + 'resolves a stale refusal for $name before fee resolution or placement', + async ({ value }) => { + const fixture = setup(); + const params: OrderParams = { + symbol: 'BTC', + orderType: 'scale', + isBuy: true, + size: '2', + // Exercise malformed runtime input at the directly callable service boundary. + expectedScaleLadder: value as OrderParams['expectedScaleLadder'], + }; + + const result = fixture.service.placeOrder({ + provider: fixture.provider, + params, + context: fixture.context, + reportOrderToDataLake: fixture.reportOrderToDataLake, + }); + + expect(await result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE, + }); + expect(fixture.resolveFee).not.toHaveBeenCalled(); + expect(fixture.placeOrder).not.toHaveBeenCalled(); + expect(fixture.reportOrderToDataLake).not.toHaveBeenCalled(); + }, + ); + + it('preserves unrelated provider execution exceptions', async () => { + const fixture = setup(); + const error = new Error('provider transport unavailable'); + fixture.placeOrder.mockRejectedValue(error); + + const result = fixture.service.placeOrder({ + provider: fixture.provider, + params: { + symbol: 'BTC', + orderType: 'market', + isBuy: true, + size: '1', + currentPrice: 100, + }, + context: fixture.context, + reportOrderToDataLake: fixture.reportOrderToDataLake, + }); + + await expect(result).rejects.toBe(error); + expect(fixture.resolveFee).toHaveBeenCalledTimes(1); + expect(fixture.placeOrder).toHaveBeenCalledTimes(1); + }); + + it('preserves successful placement when the optional expectation is omitted', async () => { + const fixture = setup(); + + const result = fixture.service.placeOrder({ + provider: fixture.provider, + params: { + symbol: 'BTC', + orderType: 'market', + isBuy: true, + size: '1', + currentPrice: 100, + }, + context: fixture.context, + reportOrderToDataLake: fixture.reportOrderToDataLake, + }); + + expect(await result).toStrictEqual({ success: true }); + expect(fixture.resolveFee).toHaveBeenCalledTimes(1); + expect(fixture.placeOrder).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 2fcdaedc37d..749f8c4cc57 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -22,6 +22,7 @@ import { resolveSubscriptionWaiverRate } from '../../../src/utils/subscriptionFe /* eslint-disable */ import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; import { + createDeferred, createMockServiceContext, createMockPerpsControllerState, createMockInfrastructure, @@ -105,6 +106,45 @@ describe('TradingService', () => { }); describe('placeOrder', () => { + it('owns bound Scale intent across a held fee resolution', async () => { + const release = createDeferred(); + mockRewardsIntegrationService.resolveFee.mockReturnValue(release.promise); + mockProvider.placeOrder.mockResolvedValue({ success: true }); + const expectation = { + prices: ['100', '200'], + sizes: ['1', '1'], + totalSize: '2', + totalNotional: '300', + minimumBaseSize: '0.1', + minimumQuoteAmount: '1', + sizeDecimals: 1, + }; + const request: OrderParams = { + symbol: 'BTC', + orderType: 'scale', + isBuy: true, + size: '2', + expectedScaleLadder: expectation, + }; + const original = JSON.parse(JSON.stringify(request)) as OrderParams; + const pending = tradingService.placeOrder({ + provider: mockProvider, + params: request, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + request.size = '200'; + expectation.prices[0] = '1'; + expectation.sizes[0] = '100'; + request.expectedScaleLadder = { ...expectation, totalSize: '200' }; + delete request.expectedScaleLadder; + release.resolve(defaultFeeResolution); + await pending; + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining(original), + ); + }); + it('preserves the subscription source through order construction', async () => { mockProvider.setUserFeeResolution = jest.fn(); const subscriptionResolution: PerpsFeeResolution = { diff --git a/packages/perps-controller/tests/src/utils/guardedHyperLiquidClient.test.ts b/packages/perps-controller/tests/src/utils/guardedHyperLiquidClient.test.ts new file mode 100644 index 00000000000..bc3287d86d9 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/guardedHyperLiquidClient.test.ts @@ -0,0 +1,165 @@ +import { ExchangeClient } from '@nktkas/hyperliquid'; + +import { createGuardedHyperLiquidClient } from '../../../src/utils/guardedHyperLiquidClient.js'; + +const order = { + orders: [ + { + a: 1, + b: true, + p: '3000', + s: '1', + r: true, + t: { limit: { tif: 'Gtc' as const } }, + }, + ], + grouping: 'na' as const, +}; + +describe('createGuardedHyperLiquidClient', () => { + it.each([false, true])( + 'reports the exact refused exchange payload and preserves the guard error when reporting throws: %s', + async (reportingThrows) => { + const request = jest.fn(); + const client = new ExchangeClient({ + wallet: { + address: '0x1111111111111111111111111111111111111111', + signTypedData: jest.fn(), + }, + transport: { isTestnet: false, request }, + }); + const refusal = new Error('scope changed'); + const payload = { signature: { r: `0x${'11'.repeat(32)}` } }; + const onDispatchRefused = jest.fn(() => { + if (reportingThrows) { + throw new Error('observer failed'); + } + }); + const guarded = createGuardedHyperLiquidClient( + client, + async () => { + throw refusal; + }, + onDispatchRefused, + ); + + await expect( + guarded.config_.transport.request('exchange', payload), + ).rejects.toBe(refusal); + + expect(onDispatchRefused.mock.calls).toStrictEqual([[payload]]); + expect(onDispatchRefused.mock.calls[0]?.[0]).toBe(payload); + expect(request).not.toHaveBeenCalled(); + }, + ); + + it('does not report a refused info request as an exchange request', async () => { + const request = jest.fn(); + const client = new ExchangeClient({ + wallet: { + address: '0x1111111111111111111111111111111111111111', + signTypedData: jest.fn(), + }, + transport: { isTestnet: false, request }, + }); + const refusal = new Error('scope changed'); + const onDispatchRefused = jest.fn(); + const guarded = createGuardedHyperLiquidClient( + client, + async () => { + throw refusal; + }, + onDispatchRefused, + ); + + await expect( + guarded.config_.transport.request('info', { type: 'meta' }), + ).rejects.toBe(refusal); + + expect(onDispatchRefused).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + }); + + it('leaves dispatched transport failures to the reporting transport', async () => { + const failure = new Error('fetch failed'); + const request = jest.fn().mockRejectedValue(failure); + const client = new ExchangeClient({ + wallet: { + address: '0x1111111111111111111111111111111111111111', + signTypedData: jest.fn(), + }, + transport: { isTestnet: false, request }, + }); + const onDispatchRefused = jest.fn(); + const guarded = createGuardedHyperLiquidClient( + client, + jest.fn().mockResolvedValue(undefined), + onDispatchRefused, + ); + const payload = { action: { type: 'cancel' } }; + + await expect( + guarded.config_.transport.request('exchange', payload), + ).rejects.toBe(failure); + + expect(request.mock.calls).toStrictEqual([ + ['exchange', payload, undefined], + ]); + expect(onDispatchRefused).not.toHaveBeenCalled(); + }); + + it('preserves SDK signing configuration without changing the shared client or transport', async () => { + const events: string[] = []; + const wallet = { + address: '0x1111111111111111111111111111111111111111' as const, + signTypedData: async (_args: unknown): Promise<`0x${string}`> => { + events.push('sign'); + return `0x${'11'.repeat(64)}1b`; + }, + }; + const request = jest.fn().mockImplementation(async () => { + events.push('dispatch'); + return { + status: 'ok', + response: { + type: 'order', + data: { statuses: [{ resting: { oid: 777 } }] }, + }, + }; + }); + const transport = { isTestnet: true, request }; + const nonceManager = jest.fn().mockResolvedValue(1234); + const client = new ExchangeClient({ + wallet, + transport, + nonceManager, + defaultExpiresAfter: 9999, + }); + const config = client.config_; + const guard = jest.fn().mockImplementation(async () => { + events.push('guard'); + }); + const guarded = createGuardedHyperLiquidClient(client, guard); + + await guarded.order(order); + + expect(events).toStrictEqual(['sign', 'guard', 'dispatch']); + expect(client.config_).toBe(config); + expect(client.config_.transport).toBe(transport); + expect(guarded.config_.wallet).toBe(wallet); + expect(guarded.config_.nonceManager).toBe(nonceManager); + expect(nonceManager).toHaveBeenCalledWith(wallet.address); + expect(request).toHaveBeenCalledWith( + 'exchange', + expect.objectContaining({ nonce: 1234, expiresAfter: 9999 }), + undefined, + ); + + guard.mockRejectedValueOnce(new Error('scope changed')); + await expect(guarded.order(order)).rejects.toThrow('scope changed'); + expect(request).toHaveBeenCalledTimes(1); + await client.order(order); + expect(request).toHaveBeenCalledTimes(2); + expect(guard).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts index 9fd837940e0..8460a4d0844 100644 --- a/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts +++ b/packages/perps-controller/tests/src/utils/lighterAdapter.test.ts @@ -46,6 +46,7 @@ describe('lighterAdapter', () => { expect(market).toStrictEqual({ name: 'BTC', szDecimals: 5, + priceDecimals: 1, maxLeverage: 25, marginTableId: 0, minimumOrderSize: 10, @@ -614,7 +615,7 @@ describe('lighterAdapter', () => { }); it.each([true, false])( - 'rejects missing reduction pnl for maker=%s', + 'preserves missing reduction pnl as unknown for maker=%s', (isMaker) => { for (const isAsk of [true, false]) { for (const size of ['0.133', '0.2']) { @@ -630,9 +631,19 @@ describe('lighterAdapter', () => { bidAccountPnl: isAsk ? '123' : undefined, }; - expect(() => - adaptFillFromLighterTrade(trade, 'SOL', isAsk ? 28 : 7), - ).toThrow('is missing valid account pnl'); + const fill = adaptFillFromLighterTrade( + trade, + 'SOL', + isAsk ? 28 : 7, + ); + + const closeDirection = isAsk ? 'Close Long' : 'Close Short'; + const flipDirection = isAsk ? 'Long > Short' : 'Short > Long'; + expect(fill.direction).toBe( + size === '0.133' ? closeDirection : flipDirection, + ); + expect(fill.startPosition).toBe(isAsk ? '0.133' : '-0.133'); + expect(fill).not.toHaveProperty('pnl'); } } }, diff --git a/packages/perps-controller/tests/src/utils/lighterAttachedOrders.test.ts b/packages/perps-controller/tests/src/utils/lighterAttachedOrders.test.ts new file mode 100644 index 00000000000..667470a7f71 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterAttachedOrders.test.ts @@ -0,0 +1,150 @@ +import { + parseLighterAttachedGroups, + correlateLighterAttachedOrders, +} from '../../../src/utils/lighterAttachedOrders.js'; +import type { LighterAttachedGroup } from '../../../src/utils/lighterAttachedOrders.js'; + +const group = (): LighterAttachedGroup => ({ + version: 1, + groupId: 'lighter-attached:testnet:wallet:28:1:101', + symbol: 'BTC', + accountIndex: 28, + apiKeyIndex: 7, + submission: 'prepared', + txHash: null, + nonce: null, + expiresAt: null, + orders: [ + [1, 101, '100', '900000', 0, 0, 1, 0, '0', -1], + [1, 102, '0', '1045000', 1, 4, 0, 1, '1100000', -1], + [1, 103, '0', '760000', 1, 2, 0, 1, '800000', -1], + ], + venueIds: [null, null, null], +}); + +describe('Lighter attached journal', () => { + it.each([ + 'missing-dispatch', + 'invalid-proof', + 'accepted-proof', + 'missing-terminal-ids', + ] as const)('rejects invalid durable settlement: %s', (invalid) => { + const value = { + ...group(), + submission: 'unknown', + txHash: 'aabbcc', + nonce: 42, + expiresAt: 1000, + orderExpiries: [1000, 1000, 1000], + nonAcceptance: 'expired', + }; + if (invalid === 'missing-dispatch') { + value.txHash = ''; + } + if (invalid === 'invalid-proof') { + value.nonAcceptance = 'absent'; + } + if (invalid === 'accepted-proof') { + value.submission = 'accepted'; + } + if (invalid === 'missing-terminal-ids') { + value.submission = 'completed'; + Reflect.deleteProperty(value, 'nonAcceptance'); + } + expect(() => parseLighterAttachedGroups(JSON.stringify([value]))).toThrow( + 'Invalid Lighter', + ); + }); + + it('retains exact non-acceptance and terminal ownership states', () => { + const value: LighterAttachedGroup = { + ...group(), + submission: 'unknown', + txHash: 'aabbcc', + nonce: 42, + expiresAt: 1000, + orderExpiries: [1000, 1000, 1000], + nonAcceptance: 'expired', + }; + expect(parseLighterAttachedGroups(JSON.stringify([value]))).toStrictEqual([ + value, + ]); + delete value.nonAcceptance; + value.submission = 'completed'; + value.venueIds = ['500', '501', '502']; + expect(parseLighterAttachedGroups(JSON.stringify([value]))).toStrictEqual([ + value, + ]); + }); + + it('retains both exact native tuple shapes', () => { + const pair = group(); + pair.orders = [pair.orders[0], pair.orders[1]]; + pair.venueIds = [null, null]; + expect(parseLighterAttachedGroups(JSON.stringify([pair]))).toStrictEqual([ + pair, + ]); + expect(parseLighterAttachedGroups(JSON.stringify([group()]))).toStrictEqual( + [group()], + ); + }); + + it.each([ + ['positive child', 1, 2, '1'], + ['same side', 1, 4, 0], + ['non-reduce child', 1, 7, 0], + ['duplicate sibling', 2, 5, 4], + ['duplicate client ID', 2, 1, 102], + ['fractional client ID', 1, 1, 1.2], + ['wrong market', 1, 0, 2], + ['negative child', 1, 2, '-1'], + ['fractional child', 1, 2, '0.2'], + ['uint32 overflow price', 1, 3, '4294967296'], + ['negative price', 1, 3, '-1'], + ['zero trigger', 1, 8, '0'], + ['invalid expiry', 1, 9, -2], + ['unequal expiry', 2, 9, 999999999], + ['nonzero parent trigger', 0, 8, '100'], + ['reduce-only parent', 0, 7, 1], + ])( + 'rejects corrupt %s before any recovery action', + (_label, orderIndex, field, value) => { + const corrupt = group(); + const orders: unknown[][] = corrupt.orders; + orders[Number(orderIndex)][Number(field)] = value; + expect(() => + parseLighterAttachedGroups(JSON.stringify([corrupt])), + ).toThrow('Invalid Lighter'); + }, + ); + + it('rejects an unknown submission without a complete exact transaction identity', () => { + expect(() => + parseLighterAttachedGroups( + JSON.stringify([{ ...group(), submission: 'unknown' }]), + ), + ).toThrow('dispatch identity'); + }); + + it('rejects a child ID reused by a different persisted parent group', () => { + const other = group(); + other.groupId = 'lighter-attached:testnet:wallet:28:1:104'; + other.orders[0][1] = 104; + const stopLoss = other.orders[2]; + if (!stopLoss) { + throw new Error('Expected paired attached fixture'); + } + stopLoss[1] = 105; + expect(() => + parseLighterAttachedGroups(JSON.stringify([group(), other])), + ).toThrow('Duplicate Lighter attached client identity'); + }); + + it('does not infer missing identities from an empty book', () => { + expect(correlateLighterAttachedOrders(group(), [])).toStrictEqual([ + null, + null, + null, + ]); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterChase.test.ts b/packages/perps-controller/tests/src/utils/lighterChase.test.ts new file mode 100644 index 00000000000..94851e9f52a --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterChase.test.ts @@ -0,0 +1,237 @@ +import type { + LighterApiOrder, + LighterBookOrder, + LighterRestTrade, +} from '../../../src/types/lighter-types.js'; +import { + identifyLighterChaseChild, + readLighterChaseQuote, + reconcileLighterChaseChild, +} from '../../../src/utils/lighterChase.js'; + +const row = ( + id: number, + price: string, + ownerAccountIndex = 99, +): LighterBookOrder => ({ + orderIndex: id, + orderId: String(id), + ownerAccountIndex, + initialBaseAmount: '1', + remainingBaseAmount: '1', + price, + orderExpiry: 1000000, + transactionTime: 100000, +}); +const book = { + code: 200, + totalBids: 3, + totalAsks: 1, + bids: [row(1, '100', 28), row(2, '99.9', 28), row(3, '99')], + asks: [row(4, '101')], +}; +const quote = { isBuy: true, accountIndex: 28, priceDecimals: 1 }; +const intent = { + owner: { + wallet: '0xabc', + network: 'testnet' as const, + accountIndex: 28, + apiKeyIndex: 7, + }, + symbol: 'BTC', + marketId: 1, + isBuy: true, + reduceOnly: false, + sizeDecimals: 5, + priceDecimals: 1, +}; +const child = { + clientOrderId: '100', + size: '0.0002', + price: '100000', + nonce: 8, +}; +const order: LighterApiOrder = { + orderIndex: 9001, + orderId: '9001', + clientOrderIndex: 100, + clientOrderId: '100', + ownerAccountIndex: 28, + marketIndex: 1, + initialBaseAmount: '0.0002', + remainingBaseAmount: '0.00015', + filledBaseAmount: '0.00005', + filledQuoteAmount: '5', + price: '100000', + isAsk: false, + type: 'limit', + timeInForce: 'post-only', + reduceOnly: 0, + status: 'canceled', + orderExpiry: 1000000, + timestamp: 100001, + nonce: 8, +}; +const trade: LighterRestTrade = { + tradeId: 1, + txHash: 'fill', + marketId: 1, + size: '0.00005', + price: '100000', + usdAmount: '5', + bidId: 9001, + askId: 9002, + bidAccountId: 28, + askAccountId: 99, + isMakerAsk: false, + timestamp: 100002, + type: 'trade', + askAccountPnl: '0', + bidAccountPnl: '0', + takerPositionSizeBefore: '0', + makerPositionSizeBefore: '0', + takerPositionSignChanged: false, + makerPositionSignChanged: false, +}; + +describe('Lighter Chase native book and fill accounting', () => { + it('subtracts all same-side account orders before choosing the fixed-tick quote', () => { + expect(readLighterChaseQuote(book, quote)).toBe('99.1'); + expect(readLighterChaseQuote(book, { ...quote, isBuy: false })).toBe( + '100.9', + ); + }); + it('never crosses a one-tick spread', () => { + const thin = { + ...book, + totalBids: 1, + bids: [row(1, '100')], + asks: [row(2, '100.1')], + }; + expect(readLighterChaseQuote(thin, quote)).toBe('100'); + expect(readLighterChaseQuote(thin, { ...quote, isBuy: false })).toBe( + '100.1', + ); + }); + it.each([ + { ...book, bids: [] }, + { ...book, asks: [row(4, '98')] }, + { ...book, bids: [row(1, '100', 28)] }, + ])('refuses missing external or crossed books', (badBook) => { + expect(() => readLighterChaseQuote(badBook, quote)).toThrow('book'); + }); + it('refuses malformed or conflicting native order identity', () => { + expect(() => + readLighterChaseQuote( + { ...book, bids: [row(3, '99'), row(3, '98')] }, + quote, + ), + ).toThrow('identity'); + }); + it.each([2075941, undefined])( + 'identifies a signed child independently of venue order nonce %s', + (nonce) => { + expect( + identifyLighterChaseChild(intent, child, { ...order, nonce }), + ).toBe('9001'); + expect( + reconcileLighterChaseChild(intent, child, { ...order, nonce }, [trade]), + ).toMatchObject({ + orderId: '9001', + terminal: true, + filledSize: '0.00005', + }); + }, + ); + it.each([ + { ownerAccountIndex: 99 }, + { marketIndex: 2 }, + { clientOrderIndex: 101, clientOrderId: '101' }, + { orderId: '9002' }, + { clientOrderId: '101' }, + { initialBaseAmount: '0.0003' }, + { price: '100001' }, + { isAsk: true }, + { timeInForce: 'good-till-time' }, + { type: 'market' }, + { reduceOnly: 1 }, + ])( + 'rejects foreign immutable order identity %j despite an independent nonce', + (override) => { + expect(() => + identifyLighterChaseChild(intent, child, { + ...order, + nonce: 2075941, + ...override, + }), + ).toThrow('identity'); + }, + ); + it('still requires a persisted signed child nonce', () => { + expect(() => + identifyLighterChaseChild(intent, { ...child, nonce: undefined }, order), + ).toThrow('identity'); + }); + it('accounts canceled partial fills from exact individual trades', () => { + expect( + reconcileLighterChaseChild(intent, child, order, [ + { ...trade, size: '0.00002', usdAmount: '2' }, + { ...trade, tradeId: 2, size: '0.00003', usdAmount: '3' }, + ]), + ).toStrictEqual({ + orderId: '9001', + terminal: true, + filledSize: '0.00005', + filledNotional: '5', + remainingSize: '0.00015', + }); + }); + it.each([{ bidIdStr: '2' }, { askIdStr: '2' }, { tradeIdStr: '2' }])( + 'rejects conflicting numeric/string trade counterparts %s', + (override) => { + expect(() => + reconcileLighterChaseChild(intent, child, order, [ + { ...trade, ...override }, + ]), + ).toThrow('identity'); + }, + ); + it('deduplicates identical fill replay without increasing execution', () => { + expect( + reconcileLighterChaseChild(intent, child, order, [trade, trade]) + .filledSize, + ).toBe('0.00005'); + expect(() => + reconcileLighterChaseChild(intent, child, order, [ + trade, + { ...trade, size: '0.0001' }, + ]), + ).toThrow('duplicate'); + }); + it('refuses missing fills, wrong ownership and overfilled children', () => { + expect(() => reconcileLighterChaseChild(intent, child, order, [])).toThrow( + 'totals', + ); + expect(() => + reconcileLighterChaseChild( + intent, + child, + { ...order, ownerAccountIndex: 99 }, + [trade], + ), + ).toThrow('identity'); + expect(() => + reconcileLighterChaseChild(intent, child, order, [ + { ...trade, bidAccountId: 99 }, + ]), + ).toThrow('identity'); + expect(() => + reconcileLighterChaseChild( + intent, + child, + { ...order, status: 'filled' }, + [trade], + ), + ).toThrow('underfilled'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterLosslessJson.test.ts b/packages/perps-controller/tests/src/utils/lighterLosslessJson.test.ts new file mode 100644 index 00000000000..67b86295bed --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterLosslessJson.test.ts @@ -0,0 +1,47 @@ +import { parseLighterLosslessJson } from '../../../src/utils/lighterLosslessJson.js'; + +describe('parseLighterLosslessJson', () => { + it('retains unquoted int64 identities and decodes ordinary JSON without a reviver', () => { + expect( + parseLighterLosslessJson( + '{"id":288230376151711745,"small":42,"negative":-9007199254740993,"array":[true,null,"\\u0061\\\"b",1.25]}', + ), + ).toMatchObject({ + id: '288230376151711745', + small: 42, + negative: '-9007199254740993', + array: [true, null, 'a"b', 1.25], + }); + }); + + it.each([ + '{"id":1,"id":2}', + '{"id":01}', + '[1,]', + 'true false', + '"\\q"', + '{"id":1e400}', + '{', + '"unterminated', + ])('rejects ambiguous or malformed JSON %s', (raw) => { + expect(() => parseLighterLosslessJson(raw)).toThrow(Error); + }); + + it('does not silently integerize fractional or exponent identity tokens', () => { + expect( + parseLighterLosslessJson('[1.00000000000000001,1e0,-0]'), + ).toStrictEqual(['1.00000000000000001', '1e0', '-0']); + }); + + it('bounds length and recursion and treats prototype keys as plain data', () => { + expect(() => parseLighterLosslessJson(' '.repeat(2_000_001))).toThrow( + Error, + ); + expect(() => + parseLighterLosslessJson(`${'['.repeat(34)}0${']'.repeat(34)}`), + ).toThrow(Error); + const parsed = parseLighterLosslessJson('{"__proto__":{"polluted":true}}'); + expect(Object.getPrototypeOf(parsed)).toBeNull(); + expect({}).not.toHaveProperty('polluted'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterNativeEdit.test.ts b/packages/perps-controller/tests/src/utils/lighterNativeEdit.test.ts new file mode 100644 index 00000000000..cbbdc5264c4 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterNativeEdit.test.ts @@ -0,0 +1,259 @@ +import type { OrderParams } from '../../../src/types/index.js'; +import type { + LighterEditableOrder, + LighterSignModifyOrderWireParams, +} from '../../../src/types/lighter-types.js'; +import { + decodeLighterModifyOrder, + lighterEditOrderId, + lighterNativeEditTarget, + parseLighterNativeEditJournal, + prepareLighterNativeEdit, + observeLighterNativeEdit, + lighterNativeEditResult, +} from '../../../src/utils/lighterNativeEdit.js'; +import type { LighterNativeEditJournal } from '../../../src/utils/lighterNativeEdit.js'; + +const orderId = '288230376151711745'; +const tuple: LighterSignModifyOrderWireParams = [ + 28, + 1, + orderId, + 100, + 910000, + 0, + 42, +]; +const now = 1_700_000_000_000; +const row: LighterEditableOrder = { + orderIndex: orderId, + clientOrderIndex: 1, + marketIndex: 1, + ownerAccountIndex: 28, + initialBaseAmount: '0.001', + remainingBaseAmount: '0.001', + filledBaseAmount: '0', + price: '90000', + isAsk: false, + type: 'limit', + timeInForce: 'good-till-time', + reduceOnly: 0, + status: 'open', + orderExpiry: 0, + timestamp: now, +}; +const market = { + symbol: 'BTC', + marketId: 1, + marketType: 'perp', + status: 'active', + takerFee: '0', + makerFee: '0', + minBaseAmount: '0.0002', + minQuoteAmount: '10', + supportedSizeDecimals: 5, + supportedPriceDecimals: 1, + supportedQuoteDecimals: 6, +}; +const params: OrderParams = { + symbol: 'BTC', + isBuy: true, + orderType: 'limit', + price: '91000', + size: '0.001', +}; + +const signed = ( + changes: Record = {}, +): { txInfo: string; txHash: string } => ({ + txInfo: JSON.stringify({ + AccountIndex: 28, + ApiKeyIndex: 7, + MarketIndex: 1, + Index: orderId, + BaseAmount: 100, + Price: 910000, + TriggerPrice: 0, + Nonce: 42, + ExpiredAt: now + 599_000, + Sig: `${'A'.repeat(107)}=`, + L2TxAttributes: null, + ...changes, + }).replace(/"Index":"(\d+)"/u, '"Index":$1'), + txHash: 'dddd000000000001', +}); +const journal = (): LighterNativeEditJournal => ({ + version: 1, + wallet: '0x123', + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 7, + startedAt: now, + original: lighterNativeEditTarget(row), + intent: prepareLighterNativeEdit(params, row, market, 28), + phase: 'attempted', + status: 'pending', + nonce: 42, + txHash: 'dddd000000000001', + expiresAt: now + 599_000, +}); +const scope = { + wallet: '0x123', + network: 'testnet' as const, + accountIndex: 28, + orderId, +}; + +describe('native Lighter edit identity and journal', () => { + it('validates the pinned ABI and returns original signed bytes unchanged', () => { + const payload = signed(); + expect(decodeLighterModifyOrder(payload, tuple, 7, now, now)).toStrictEqual( + { ...payload, expiresAt: now + 599_000 }, + ); + expect(lighterEditOrderId('1152921504606846975')).toBe( + '1152921504606846975', + ); + }); + + it.each([ + 0, + -1, + Number(orderId), + '01', + '+1', + '1.0', + '1e1', + '1152921504606846976', + '9'.repeat(100), + ])('refuses invalid native identity %s', (id) => { + expect(() => lighterEditOrderId(id)).toThrow(Error); + }); + + it.each([ + { AccountIndex: 99 }, + { ApiKeyIndex: 8 }, + { MarketIndex: 2 }, + { Index: '288230376151711746' }, + { BaseAmount: 100.5 }, + { Price: 4294967296 }, + { TriggerPrice: 1 }, + { Nonce: 43 }, + { ExpiredAt: now + 599_001 }, + { Sig: 'bad' }, + { L2TxAttributes: {} }, + { privateExtension: 'secret' }, + ])('refuses signer identity or shape mutation %j', (changes) => { + expect(() => + decodeLighterModifyOrder(signed(changes), tuple, 7, now, now), + ).toThrow(Error); + }); + + it('rejects duplicate and rounded fractional identities and extra ABI arguments', () => { + expect(() => + decodeLighterModifyOrder( + { + ...signed(), + txInfo: signed().txInfo.replace( + '"Nonce":42', + '"Nonce":42,"Nonce":42', + ), + }, + tuple, + 7, + now, + now, + ), + ).toThrow(Error); + expect(() => + decodeLighterModifyOrder( + { + ...signed(), + txInfo: signed().txInfo.replace( + '"Nonce":42', + '"Nonce":42.00000000000000001', + ), + }, + tuple, + 7, + now, + now, + ), + ).toThrow(Error); + expect(() => + decodeLighterModifyOrder( + signed(), + [...tuple, 1] as unknown as LighterSignModifyOrderWireParams, + 7, + now, + now, + ), + ).toThrow(Error); + }); + + it('decodes absent and pending storage without discarding original ownership', () => { + expect(parseLighterNativeEditJournal(null, scope)).toBeNull(); + expect( + parseLighterNativeEditJournal(JSON.stringify(journal()), scope), + ).toMatchObject(journal()); + }); + + it.each([ + { wallet: 'foreign' }, + { network: 'mainnet' }, + { accountIndex: 99 }, + { apiKeyIndex: 256 }, + { phase: 'prepared' }, + { nonce: 1.5 }, + { txHash: 'private-error' }, + { expiresAt: 0 }, + { status: 'settled' }, + { status: 'terminal' }, + { status: 'failed' }, + { rawSecret: 'must not persist' }, + ])('refuses corrupt or falsely settled durable storage %j', (changes) => { + expect(() => + parseLighterNativeEditJournal( + JSON.stringify({ ...journal(), ...changes }), + scope, + ), + ).toThrow(Error); + }); + + it('refuses foreign exact client lookup and missing fill observation', () => { + expect(() => + observeLighterNativeEdit({ ...row, ownerAccountIndex: 99 }, journal()), + ).toThrow(Error); + expect(() => + observeLighterNativeEdit( + { ...row, filledBaseAmount: undefined }, + journal(), + ), + ).toThrow(Error); + expect(() => + observeLighterNativeEdit({ ...row, status: 'unknown' }, journal()), + ).toThrow(Error); + }); + + it('projects recorded intent separately from a different requested mutation', () => { + const record = journal(); + record.status = 'settled'; + record.resolution = 'executed'; + record.observation = observeLighterNativeEdit( + { ...row, price: '91000' }, + record, + ); + expect(lighterNativeEditResult(record, params)).toMatchObject({ + success: true, + orderEdit: { status: 'settled' }, + }); + expect( + lighterNativeEditResult(record, { ...params, price: '92000' }), + ).toMatchObject({ + success: false, + orderEdit: { status: 'settled', requestedPrice: '91000' }, + }); + expect( + lighterNativeEditResult(record, { ...params, symbol: 'ETH' }).success, + ).toBe(false); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterScaleOrders.test.ts b/packages/perps-controller/tests/src/utils/lighterScaleOrders.test.ts new file mode 100644 index 00000000000..c28879018a0 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterScaleOrders.test.ts @@ -0,0 +1,381 @@ +import { BigNumber } from 'bignumber.js'; + +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { OrderParams } from '../../../src/types/index.js'; +import type { LighterOrderBookMeta } from '../../../src/types/lighter-types.js'; +import { + buildLighterScaleLadder, + normalizeLighterScalePrices, + parseLighterScaleGroups, + toLighterScaleGroup, +} from '../../../src/utils/lighterScaleOrders.js'; +import type { LighterScaleGroup } from '../../../src/utils/lighterScaleOrders.js'; + +const market: LighterOrderBookMeta = { + symbol: 'SOL', + marketId: 4097, + marketType: 'perp', + status: 'active', + takerFee: '0', + makerFee: '0', + minBaseAmount: '0.05', + minQuoteAmount: '10', + supportedSizeDecimals: 3, + supportedPriceDecimals: 3, + supportedQuoteDecimals: 6, +}; +const intent: OrderParams = { + symbol: 'SOL', + orderType: 'scale', + isBuy: false, + size: '0.144', + scaleMinPrice: '125', + scaleMaxPrice: '156.25', + scaleNumOrders: 2, + scaleSkew: 0.8, +}; + +/** Produce independent durable fixtures. + * @returns A two-child stopped ladder with one resting child. + */ +function groupFixture(): LighterScaleGroup { + return { + version: 1, + groupId: + 'lighter-scale:testnet:0x1111111111111111111111111111111111111111:28:100', + symbol: 'SOL', + accountIndex: 28, + apiKeyIndex: 7, + marketId: 4097, + isBuy: false, + reduceOnly: false, + createdAt: 1000, + walletAddress: '0x1111111111111111111111111111111111111111', + network: 'testnet', + sizeDecimals: 3, + priceDecimals: 3, + placementStopped: true, + rungs: [ + { + clientOrderId: 100, + price: '125', + size: '0.08', + priceInt: 125000, + sizeInt: 80, + state: 'resting', + nonce: 42, + txHash: 'aabb', + expiresAt: 10000, + orderExpiry: 20000, + orderId: '800', + filledSize: '0', + }, + { + clientOrderId: 101, + price: '156.25', + size: '0.064', + priceInt: 156250, + sizeInt: 64, + state: 'prepared', + nonce: null, + txHash: null, + expiresAt: null, + orderExpiry: null, + }, + ], + }; +} + +describe('Lighter Scale exact builder', () => { + it('accepts children at the native maximum even when their aggregate exceeds it', () => { + const result = buildLighterScaleLadder( + { ...intent, size: '562.94995342131', scaleSkew: 1 }, + { ...market, supportedSizeDecimals: 12 }, + ); + expect(result.sizes).toStrictEqual([ + '281.474976710655', + '281.474976710655', + ]); + }); + it.each([ + { size: '562.949953421312', scaleSkew: 1 }, + { size: '600', scaleSkew: 1 }, + { size: '500', scaleSkew: 2 }, + ])('rejects a child above the native amount maximum %j', (override) => { + expect(() => + buildLighterScaleLadder( + { ...intent, ...override }, + { ...market, supportedSizeDecimals: 12 }, + ), + ).toThrow('Lighter Scale child exceeds native base amount'); + }); + + it.each([0.5, Number.NaN, Number.POSITIVE_INFINITY, -1, 21])( + 'rejects invalid price precision %s before applying decimal shifts', + (decimals) => { + expect(() => + normalizeLighterScalePrices('125', '156.25', 2, decimals), + ).toThrow('Invalid Lighter Scale precision'); + }, + ); + + it.each([ + [ + { scaleMinPrice: undefined }, + PERPS_ERROR_CODES.ORDER_SCALE_RANGE_REQUIRED, + ], + [ + { scaleNumOrders: undefined }, + PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID, + ], + [{ scaleNumOrders: 1 }, PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID], + [ + { scaleMinPrice: '125.0001', isBuy: false }, + PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, + ], + [{ scaleMaxPrice: '125' }, PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID], + [{ size: '0.0001' }, PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL], + [{ usdAmount: '1' }, PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL], + ] as const)('returns shared Scale error codes for %j', (override, code) => { + expect(() => + buildLighterScaleLadder({ ...intent, ...override }, market), + ).toThrow(code); + }); + + it('conserves skewed integer lots and the exact quote budget', () => { + const explicit = buildLighterScaleLadder(intent, market); + const budget = buildLighterScaleLadder( + { ...intent, usdAmount: '20' }, + market, + ); + expect(explicit).toStrictEqual({ + prices: ['125', '156.25'], + sizes: ['0.08', '0.064'], + size: '0.144', + notional: '20', + }); + expect(budget).toStrictEqual(explicit); + }); + + it('floors explicit dust without increasing exposure', () => { + expect( + buildLighterScaleLadder({ ...intent, size: '0.144999' }, market).size, + ).toBe('0.144'); + }); + + it.each([2, 3, 7, 20])('conserves exact lots for %s rungs', (count) => { + const result = buildLighterScaleLadder( + { ...intent, size: '19.997', scaleNumOrders: count, scaleSkew: 1.7 }, + market, + ); + expect( + result.sizes + .reduce((sum, size) => sum.plus(size), new BigNumber(0)) + .toFixed(), + ).toBe('19.997'); + expect(new Set(result.prices).size).toBe(count); + }); + + it.each([ + { scaleMinPrice: '125junk' }, + { scaleMaxPrice: '125.001', scaleNumOrders: 3 }, + { size: '0.143' }, + { size: '9007199254740.992' }, + { scaleSkew: Number.POSITIVE_INFINITY }, + { usdAmount: '19.99' }, + { maxSlippageBps: 100 }, + { isFullClose: true }, + ])('rejects malformed, underfunded or unsupported intent: %j', (override) => { + expect(() => + buildLighterScaleLadder({ ...intent, ...override }, market), + ).toThrow(/scale/iu); + }); + + it('uses exact decimal ticks at the unsigned 32-bit wire boundary', () => { + expect( + normalizeLighterScalePrices('4294967.294', '4294967.295', 2, 3), + ).toStrictEqual(['4294967.294', '4294967.295']); + expect(() => + normalizeLighterScalePrices('4294967.294', '4294967.296', 2, 3), + ).toThrow(/scale/iu); + }); +}); + +describe('Lighter Scale durable receipt', () => { + it.each([undefined, false, true])( + 'round trips cancellation acknowledgement %s without inferring legacy acceptance', + (acknowledged) => { + const group = groupFixture(); + group.rungs[0].cancelAttempt = { + apiKeyIndex: 7, + nonce: 44, + txHash: 'bbbb000000000001', + expiresAt: 20000, + acknowledged, + }; + const [parsed] = parseLighterScaleGroups(JSON.stringify([group])); + expect(parsed.rungs[0].cancelAttempt?.acknowledged).toBe(acknowledged); + }, + ); + + it.each([null, 0, 'false'])( + 'rejects malformed cancellation acknowledgement %s', + (acknowledged) => { + const group = groupFixture(); + const raw = { + ...group, + rungs: [ + { + ...group.rungs[0], + cancelAttempt: { + apiKeyIndex: 7, + nonce: 44, + txHash: 'bbbb000000000001', + expiresAt: 20000, + acknowledged, + }, + }, + group.rungs[1], + ], + }; + expect(() => parseLighterScaleGroups(JSON.stringify([raw]))).toThrow( + 'Invalid Lighter Scale cancellation attempt', + ); + }, + ); + + it('round trips an unresolved cancellation and accepts legacy rungs without one', () => { + const group = groupFixture(); + expect(parseLighterScaleGroups(JSON.stringify([group]))).toStrictEqual([ + group, + ]); + group.rungs[0].cancelAttempt = { + apiKeyIndex: 7, + nonce: 44, + txHash: 'bbbb000000000001', + expiresAt: 20000, + }; + expect(parseLighterScaleGroups(JSON.stringify([group]))).toStrictEqual([ + group, + ]); + }); + + it.each([ + null, + {}, + { apiKeyIndex: 7, nonce: -1, txHash: 'bbbb000000000001', expiresAt: 20000 }, + { apiKeyIndex: 7, nonce: 44, txHash: 'wrong', expiresAt: 20000 }, + { apiKeyIndex: 7, nonce: 44, txHash: 'bbbb000000000001', expiresAt: 0 }, + ])('rejects malformed cancellation ownership %j', (attempt) => { + const group = groupFixture(); + const raw = { + ...group, + rungs: [{ ...group.rungs[0], cancelAttempt: attempt }, group.rungs[1]], + }; + expect(() => parseLighterScaleGroups(JSON.stringify([raw]))).toThrow( + 'Invalid Lighter Scale cancellation attempt', + ); + }); + + it.each(['orderId', 'placementStopped'] as const)( + 'rejects cancellation without its %s ownership fence', + (field) => { + const group = groupFixture(); + group.rungs[0].cancelAttempt = { + apiKeyIndex: 7, + nonce: 44, + txHash: 'bbbb000000000001', + expiresAt: 20000, + }; + if (field === 'orderId') { + delete group.rungs[0].orderId; + } else { + group.placementStopped = false; + } + expect(() => parseLighterScaleGroups(JSON.stringify([group]))).toThrow( + 'Invalid Lighter Scale cancellation attempt', + ); + }, + ); + + it('validates persisted native base amounts at the maximum and one unit above', () => { + const group = groupFixture(); + group.sizeDecimals = 12; + group.rungs[0].sizeInt = 281474976710655; + group.rungs[0].size = '281.474976710655'; + group.rungs[1].size = '0.000000000064'; + expect(parseLighterScaleGroups(JSON.stringify([group]))).toStrictEqual([ + group, + ]); + group.rungs[0].sizeInt = 281474976710656; + group.rungs[0].size = '281.474976710656'; + expect(() => parseLighterScaleGroups(JSON.stringify([group]))).toThrow( + /scale/iu, + ); + }); + + it('retains uncertainty without fabricating child identity or acceptance', () => { + const group = groupFixture(); + group.rungs[0].state = 'unknown'; + delete group.rungs[0].orderId; + delete group.rungs[0].filledSize; + expect(toLighterScaleGroup(group)).toMatchObject({ + state: 'unknown', + submittedSize: '0.08', + childOrderIds: [], + acceptedChildren: [], + }); + expect(toLighterScaleGroup(group).acceptedSize).toBeUndefined(); + expect(toLighterScaleGroup(group).filledSize).toBeUndefined(); + }); + + it('preserves canceled acceptance while excluding it from resting child IDs', () => { + const group = groupFixture(); + group.rungs[0].state = 'canceled'; + const receipt = toLighterScaleGroup(group); + expect(receipt).toMatchObject({ + state: 'terminal', + acceptedSize: '0.08', + childOrderIds: [], + acceptedChildren: [{ state: 'canceled', orderId: '800' }], + filledSize: '0', + }); + expect(receipt).not.toHaveProperty('averagePrice'); + }); + + it('does not substitute the limit price for execution evidence', () => { + const group = groupFixture(); + group.rungs[0].state = 'filled'; + group.rungs[0].filledSize = '0.08'; + expect(toLighterScaleGroup(group).filledSize).toBe('0.08'); + expect(toLighterScaleGroup(group)).not.toHaveProperty('averagePrice'); + }); + + it('round trips validated immutable scope and rejects duplicated identities', () => { + const group = groupFixture(); + expect(parseLighterScaleGroups(JSON.stringify([group]))).toStrictEqual([ + group, + ]); + expect(() => + parseLighterScaleGroups(JSON.stringify([group, group])), + ).toThrow(/scale/iu); + group.rungs[1].clientOrderId = group.rungs[0].clientOrderId; + expect(() => parseLighterScaleGroups(JSON.stringify([group]))).toThrow( + /scale/iu, + ); + }); + + it.each(['priceInt', 'sizeInt', 'nonce', 'orderId', 'orderExpiry'] as const)( + 'rejects invalid persisted %s', + (field) => { + const group = groupFixture(); + const raw = JSON.parse(JSON.stringify(group)) as { + rungs: Record[]; + }; + raw.rungs[0][field] = null; + expect(() => parseLighterScaleGroups(JSON.stringify([raw]))).toThrow( + /scale/iu, + ); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterTwap.test.ts b/packages/perps-controller/tests/src/utils/lighterTwap.test.ts new file mode 100644 index 00000000000..66558c379bb --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterTwap.test.ts @@ -0,0 +1,73 @@ +import { prepareLighterTwapOrder } from '../../../src/utils/lighterTwap.js'; + +const intent = { + size: '0.125', + referencePrice: '100.03', + sizeDecimals: 3, + priceDecimals: 2, + isBuy: true, + slippage: '0.01', + durationMinutes: 1, + nowMilliseconds: 1_800_000_000_000, + randomize: false, + reduceOnly: false, +}; + +describe('prepareLighterTwapOrder', () => { + it('prepares exact native type6 fields without borrowing Hyperliquid duration limits', () => { + expect(prepareLighterTwapOrder(intent)).toStrictEqual({ + baseAmount: '125', + price: '10103', + isAsk: 0, + orderType: 6, + timeInForce: 1, + reduceOnly: 0, + triggerPrice: '0', + orderExpiry: 1_800_000_060_000, + }); + }); + + it('rounds a sell bound inward so price protection never widens', () => { + expect( + prepareLighterTwapOrder({ ...intent, isBuy: false, reduceOnly: true }), + ).toMatchObject({ price: '9903', isAsk: 1, reduceOnly: 1 }); + }); + + it('preserves the exact maximum 48-bit amount', () => { + expect( + prepareLighterTwapOrder({ ...intent, size: '281474976710.655' }), + ).toMatchObject({ baseAmount: '281474976710655' }); + }); + + it.each([ + { size: '281474976710.656' }, + { size: '0.0001' }, + { size: '0' }, + { size: '-1' }, + { size: 'NaN' }, + { size: '1tail' }, + { size: 'Infinity' }, + { referencePrice: '0' }, + { referencePrice: 'Infinity' }, + { referencePrice: '42949672.96', slippage: '0' }, + { referencePrice: '0.001', slippage: '0' }, + { slippage: '-0.1' }, + { slippage: '1' }, + { slippage: 'Infinity' }, + { sizeDecimals: 1.5 }, + { sizeDecimals: -1 }, + { priceDecimals: 256 }, + { durationMinutes: 0 }, + { durationMinutes: -1 }, + { durationMinutes: 0.5 }, + { durationMinutes: Number.MAX_SAFE_INTEGER }, + { nowMilliseconds: -1 }, + { nowMilliseconds: Number.NaN }, + { nowMilliseconds: Number.MAX_SAFE_INTEGER }, + { randomize: true }, + ])('rejects invalid wire intent %j', (override) => { + expect(() => prepareLighterTwapOrder({ ...intent, ...override })).toThrow( + /Lighter native TWAP/u, + ); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/lighterTwapReconciliation.test.ts b/packages/perps-controller/tests/src/utils/lighterTwapReconciliation.test.ts new file mode 100644 index 00000000000..a9d99f60e83 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/lighterTwapReconciliation.test.ts @@ -0,0 +1,246 @@ +import type { LighterTwapRecord } from '../../../src/services/LighterTwapService.js'; +import type { + LighterApiOrder, + LighterRestTrade, +} from '../../../src/types/lighter-types.js'; +import { reconcileLighterTwapObservation } from '../../../src/utils/lighterTwapReconciliation.js'; + +const record: LighterTwapRecord = { + intent: { + owner: { + wallet: '0xabc', + network: 'testnet', + accountIndex: 28, + apiKeyIndex: 3, + }, + symbol: 'SOL', + marketId: 4097, + clientOrderId: '123', + size: '0.15', + price: '121', + isBuy: true, + reduceOnly: false, + sizeDecimals: 3, + priceDecimals: 3, + durationMinutes: 1, + startedAt: 1_800_000_000_000, + orderExpiry: 1_800_000_060_000, + }, + placement: { + phase: 'succeeded', + nonce: 4, + txHash: 'abcdabcd', + expiresAt: 1_800_000_100_000, + }, + cancellations: [], + terminalConfirmed: false, +}; +const parent: LighterApiOrder = { + orderIndex: 1000, + orderId: '1000', + clientOrderIndex: 123, + clientOrderId: '123', + marketIndex: 4097, + ownerAccountIndex: 28, + initialBaseAmount: '0.15', + remainingBaseAmount: '0.10', + filledBaseAmount: '0.05', + filledQuoteAmount: '6', + price: '121', + isAsk: false, + type: 'twap', + timeInForce: 'good-till-time', + reduceOnly: false, + status: 'in-progress', + orderExpiry: record.intent.orderExpiry, + timestamp: 1_800_000_000, + nonce: 4, +}; +const child: LighterApiOrder = { + ...parent, + orderIndex: 1001, + orderId: '1001', + clientOrderIndex: 124, + clientOrderId: '124', + parentOrderIndex: 1000, + parentOrderId: '1000', + type: 'twap-sub', + initialBaseAmount: '0.05', + remainingBaseAmount: '0', + status: 'filled', +}; +const trade: LighterRestTrade = { + tradeId: 1, + txHash: 'abc1', + type: 'trade', + marketId: 4097, + size: '0.05', + price: '120', + usdAmount: '6', + askId: 999, + bidId: 1001, + askAccountId: 99, + bidAccountId: 28, + isMakerAsk: true, + timestamp: 1_800_000_001_000, + makerPositionSizeBefore: '0', + takerPositionSizeBefore: '0', +}; +const observe = ( + rows = [parent, child], + trades = [trade], +): ReturnType => + reconcileLighterTwapObservation(record, rows, trades, 1_800_000_020_000); + +describe('native TWAP parent/child reconciliation', () => { + it('joins exact children and unique trades without double-counting parent totals', () => { + const result = observe(); + expect(result.order).toMatchObject({ + orderId: '1000', + size: '0.15', + executedSize: '0.05', + remainingSize: '0.1', + executedNotional: '6', + averagePrice: '120', + status: 'active', + fills: [{ fillId: '1', orderId: '1001', transactionHash: 'abc1' }], + }); + expect(result.terminalObserved).toBe(false); + }); + + it('deduplicates identical trade replays', () => { + expect(observe([parent, child], [trade, trade]).order.executedSize).toBe( + '0.05', + ); + }); + + it('refuses conflicting trade replays', () => { + expect(() => + observe([parent, child], [trade, { ...trade, size: '0.04' }]), + ).toThrow('conflicting'); + }); + + it('refuses missing fills rather than inventing zero execution', () => { + expect(() => observe([parent, child], [])).toThrow('fill totals'); + }); + + it('rejects wrong parent ownership, nonce, expiry and order ID', () => { + for (const override of [ + { ownerAccountIndex: 29 }, + { nonce: 5 }, + { orderExpiry: 1 }, + ]) { + expect(() => observe([{ ...parent, ...override }, child])).toThrow( + 'parent does not match immutable signed intent', + ); + } + }); + + it('rejects a conflicting parent order ID', () => { + expect(() => observe([{ ...parent, orderId: '1002' }, child])).toThrow( + 'identity is unsafe or inconsistent', + ); + }); + + it('rejects children with conflicting numeric and string parent links', () => { + expect(() => + observe([parent, { ...child, parentOrderId: '1002' }]), + ).toThrow('parent linkage'); + }); + + it('retains underfilled expiry and cancellation as terminal observations only', () => { + expect( + observe([ + { ...parent, status: 'canceled-expired', remainingBaseAmount: '0' }, + child, + ]).order.status, + ).toBe('completed_underfilled'); + expect( + observe([ + { ...parent, status: 'canceled', remainingBaseAmount: '0' }, + child, + ]).terminalObserved, + ).toBe(true); + }); + + it('rejects parent totals that disagree with complete child fills', () => { + expect(() => + observe([{ ...parent, filledQuoteAmount: '7' }, child]), + ).toThrow('parent fill totals'); + }); + describe('TWAP review identity and totals', () => { + it.each([{ bidIdStr: '1002' }, { askIdStr: '1002' }, { tradeIdStr: '2' }])( + 'rejects conflicting native numeric/string trade IDs %s', + (override) => { + expect(() => + observe([parent, child], [{ ...trade, ...override }]), + ).toThrow('identity'); + }, + ); + it('accepts matching native string counterparts', () => { + expect( + observe( + [parent, child], + [{ ...trade, bidIdStr: '1001', askIdStr: '999', tradeIdStr: '1' }], + ).order.executedSize, + ).toBe('0.05'); + }); + it('allows bounded device clock skew for trade provenance', () => { + expect( + observe( + [parent, child], + [{ ...trade, timestamp: record.intent.startedAt - 1000 }], + ).order.executedSize, + ).toBe('0.05'); + expect(() => + observe( + [parent, child], + [{ ...trade, timestamp: record.intent.startedAt - 60000 }], + ), + ).toThrow('provenance'); + }); + it('sums unequal children and multiple fills without copied parent totals', () => { + const first = { + ...child, + filledBaseAmount: '0.03', + filledQuoteAmount: '3.59', + initialBaseAmount: '0.03', + }; + const second = { + ...child, + orderIndex: 1002, + orderId: '1002', + clientOrderIndex: 125, + clientOrderId: '125', + initialBaseAmount: '0.02', + filledBaseAmount: '0.02', + filledQuoteAmount: '2.42', + }; + const result = observe( + [{ ...parent, filledQuoteAmount: '6.01' }, first, second], + [ + { + ...trade, + tradeId: 1, + size: '0.01', + price: '119', + usdAmount: '1.19', + }, + { ...trade, tradeId: 2, size: '0.02', usdAmount: '2.4' }, + { + ...trade, + tradeId: 3, + bidId: 1002, + size: '0.02', + price: '121', + usdAmount: '2.42', + }, + ], + ); + expect(result.order.executedSize).toBe('0.05'); + expect(result.order.executedNotional).toBe('6.01'); + expect(result.order.averagePrice).toBe('120.2'); + expect(result.order.fills).toHaveLength(3); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/positionProtection.test.ts b/packages/perps-controller/tests/src/utils/positionProtection.test.ts new file mode 100644 index 00000000000..be2688cb354 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/positionProtection.test.ts @@ -0,0 +1,33 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { assertExpectedPosition } from '../../../src/utils/positionProtection.js'; + +describe('assertExpectedPosition', () => { + it('allows absent opt-in and equivalent decimal representations', () => { + expect(() => assertExpectedPosition(undefined, undefined)).not.toThrow(); + expect(() => + assertExpectedPosition( + { size: '-1.50', entryPrice: '3000.00' }, + { size: '-1.5', entryPrice: '3000' }, + ), + ).not.toThrow(); + }); + + it.each([ + { size: '0', entryPrice: '3000' }, + { size: '1', entryPrice: '0' }, + { size: '1', entryPrice: '-1' }, + { size: '1e2', entryPrice: '3000' }, + { size: 'NaN', entryPrice: '3000' }, + { size: '1', entryPrice: '' }, + ])('rejects invalid matching snapshots %j', (snapshot) => { + expect(() => assertExpectedPosition(snapshot, snapshot)).toThrow( + PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + ); + }); + + it('rejects a disappeared position', () => { + expect(() => + assertExpectedPosition({ size: '1', entryPrice: '3000' }, undefined), + ).toThrow(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); + }); +}); diff --git a/tsc-suppressions.json b/tsc-suppressions.json index 2b5f58943bd..933ee0ba81d 100644 --- a/tsc-suppressions.json +++ b/tsc-suppressions.json @@ -688,7 +688,7 @@ }, "packages/perps-controller/tests/src/PerpsController.operations.test.ts": { "TS2304": { - "count": 3 + "count": 2 }, "TS2322": { "count": 3 diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 4fe27b7c168..9ecd89b6316 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -13,6 +13,12 @@ * `jest.config.packages.cjs`. */ "paths": { + "@metamask/perps-controller/constants/*": [ + "${configDir}/../perps-controller/src/constants/*.ts" + ], + "@metamask/perps-controller/utils/*": [ + "${configDir}/../perps-controller/src/utils/*.ts" + ], "@metamask/json-rpc-engine/v2": [ "${configDir}/../json-rpc-engine/src/v2/index.ts" ],